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..d6e11982 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
@@ -56,6 +56,20 @@ actual object PlayerSettingsStorage {
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,
@@ -92,6 +106,20 @@ actual object PlayerSettingsStorage {
nextEpisodeThresholdMinutesBeforeEndKey,
useLibassKey,
libassRenderTypeKey,
+ iosVideoOutputPresetKey,
+ iosToneMappingModeKey,
+ iosTargetPrimariesKey,
+ iosTargetTransferKey,
+ iosHardwareDecoderModeKey,
+ iosExtendedDynamicRangeEnabledKey,
+ iosTargetColorspaceHintEnabledKey,
+ iosHdrComputePeakEnabledKey,
+ iosDebandEnabledKey,
+ iosInterpolationEnabledKey,
+ iosBrightnessKey,
+ iosContrastKey,
+ iosSaturationKey,
+ iosGammaKey,
)
private var preferences: SharedPreferences? = null
@@ -652,6 +680,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)) }
@@ -688,6 +830,20 @@ actual object PlayerSettingsStorage {
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) {
@@ -732,5 +888,19 @@ actual object PlayerSettingsStorage {
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/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml
index 180d60c4..6bb55c30 100644
--- a/composeApp/src/androidMain/res/xml/locale_config.xml
+++ b/composeApp/src/androidMain/res/xml/locale_config.xml
@@ -11,4 +11,5 @@
+
diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml
new file mode 100644
index 00000000..1fa4e846
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml
@@ -0,0 +1,1335 @@
+
+ Datakilder, anerkjennelser og plattformlisenser
+ Åpen anerkjennelse og bidragsytere
+ Tilbake
+ Avbryt
+ Lukk
+ Slett
+ Ferdig
+ Rediger
+ Importer
+ Neste
+ OK
+ Spill av
+ Forrige
+ Fjern
+ Endre rekkefølge
+ Tilbakestill til standard
+ Gjenoppta
+ Prøv på nytt
+ Lagre
+ Valider
+ Installerer
+ Tillegg
+ Aktiv
+ %1$d kataloger
+ Konfigurerbar
+ Oppdaterer
+ %1$d ressurser
+ Utilgjengelig
+ Konfigurer tillegg
+ Slett tillegg
+ Legg til en manifest-URL for å starte lasting av kataloger, metadata, strømmer eller undertekster i Nuvio.
+ Ingen tillegg installert ennå.
+ Skriv inn en tilleggs-URL.
+ Tilleggs-URL
+ Installer tillegg
+ Laster manifest-detaljer…
+ Validerer manifest-URL og laster tilleggsdetaljer før installasjon.
+ Sjekker tillegg
+ Installasjon mislyktes
+ %1$s ble validert og lagt til.
+ Tillegg installert
+ Flytt tillegg ned
+ Flytt tillegg opp
+ Aktiv
+ Tillegg
+ Kataloger
+ Oppdater tillegg
+ Legg til tillegg
+ Installerte tillegg
+ Oversikt
+ %1$d ID-regler
+ Versjon %1$s
+ Valgt
+ Kopier JSON
+ %1$d samling(er), %2$d mappe(r)
+ Slette «%1$s»? Dette kan ikke angres.
+ Slett samling
+ Legg til katalog
+ Legg til mappe
+ Alle sjangre
+ Legg til kataloger fra installerte tillegg for å definere hva denne mappen viser.
+ Ingen katalogkilder ennå
+ Velg
+ Emoji
+ Bilde-URL
+ Ingen
+ Forside
+ Opprett samling
+ Ferdig
+ Rediger samling
+ Rediger mappe
+ Sett mappe-identitet, presentasjon og katalogkilder med samme struktur som hovedredigeringsvinduet.
+ Legg til en for å komme i gang.
+ Ingen mapper ennå
+ Mapper
+ Sjangerfilter
+ Vis kun forsidebildet
+ Skjul tittel
+ Ny mappe
+ Vis denne samlingen over alle vanlige hjemmekataloger. Flere festede samlinger følger opprettelsesrekkefølge.
+ Fest over kataloger
+ Bakgrunnsbilde-URL (valgfritt)
+ Mappenavn
+ Animert GIF-URL (spilles kun når fokusert)
+ Samlingens navn
+ Lagre endringer
+ Lagre
+ Utseende
+ Grunnleggende
+ Katalogkilder
+ Velg hvilke tilleggskataloger denne mappen skal samle.
+ Velg kataloger
+ Velg sjanger
+ %1$d valgt
+ %1$d kataloger
+ %1$d valgt
+ Plakat
+ Kvadrat
+ Bred
+ Kombiner alle kataloger i én fane
+ Vis «Alle»-fane
+ Spill av konfigurert GIF i stedet for statisk forside når tilgjengelig.
+ Vis GIF når konfigurert
+ %1$d kilde(r) · %2$s
+ Flisform
+ Rader
+ Faner
+ Visningsmodus
+ TMDB-kilder
+ Offentlig liste
+ Produksjon
+ Nettverk
+ Samling
+ Person
+ Regissør
+ Tilpasset
+ Velg en ferdig kilde. Du kan redigere eller fjerne den etterpå.
+ Lim inn en offentlig TMDB-list-URL eller bare nummeret fra URL-en.
+ Søk etter studio-navn, eller lim inn TMDB company ID/URL.
+ Skriv inn et nettverks-ID. Vanlige nettverk finnes i forhåndsvalg.
+ Søk etter en film-samling eller lim inn collection ID fra TMDB.
+ Skriv inn TMDB person-ID eller URL for å bygge rad fra rollebesetning.
+ Skriv inn TMDB person-ID eller URL for å bygge rad fra regissør-credits.
+ Bygg en live TMDB-rad med valgfrie filtre.
+ Offentlig TMDB-liste
+ Nettverks-ID
+ Samling-ID
+ Person-ID
+ Produksjonsselskap navn, ID eller URL
+ TMDB ID eller URL
+ https://www.themoviedb.org/list/8504994 eller 8504994
+ 213 for Netflix, 49 for HBO, 2739 for Disney+
+ 10 for Star Wars Collection
+ Marvel Studios, 420, eller company URL
+ 31 for Tom Hanks, eller person URL
+ Eksempler: Marvel Studios, 420, eller https://www.themoviedb.org/company/420.
+ Eksempel: Star Wars Collection, Harry Potter Collection, eller en samling-URL.
+ Eksempel-IDer: Netflix 213, HBO 49, Disney+ 2739.
+ Eksempel: https://www.themoviedb.org/list/8504994 eller 8504994.
+ Eksempel: https://www.themoviedb.org/person/31-tom-hanks eller 31.
+ Visningstittel
+ Vises som rad-/fane-navn. Hvis tom, lager Nuvio en automatisk.
+ Marvel-filmer, Netflix Originals, Pixar
+ Tom Hanks-filmer, Favorittskuespillere
+ Christopher Nolan-filmer, Favorittregissører
+ Beste actionfilmer, Koreanske dramaer, 2024-animasjon
+ Søkeresultater
+ TMDB-samling
+ TMDB-selskap %1$d
+ TMDB-samling %1$d
+ Type
+ Filmer
+ Serier
+ Begge
+ Sortering
+ Filtre
+ La feltene være tomme hvis du ikke trenger filteret.
+ Hurtigsjangre
+ Hurtigspråk
+ Hurtigland
+ Hurtignøkkelord
+ Hurtigstudioer
+ Hurtignettverk
+ Sjanger-IDer
+ Bruk TMDB sjanger-numre. Skill med komma for AND, eller pipe for OR.
+ Utgivelses- eller sendedato fra
+ Utgivelses- eller sendedato til
+ Bruk YYYY-MM-DD, f.eks. 2024-01-01.
+ Minimumsvurdering
+ Maksimumsvurdering
+ TMDB-vurdering fra 0 til 10. Eksempel: 7.0.
+ Minimum stemmer
+ Bruk dette for å unngå obskure titler med få stemmer. Eksempel: 100.
+ Originalspråk
+ Bruk to-bokstavs språkkoder, f.eks. en, ko, ja, hi.
+ Opprinnelsesland
+ Bruk to-bokstavs landkoder, f.eks. US, KR, JP, IN.
+ Nøkkelord-IDer
+ Bruk TMDB nøkkelord-numre.
+ 9715 for superhero
+ Selskap-IDer
+ Bruk studio/selskap-IDer.
+ 420 for Marvel Studios
+ Nettverks-IDer
+ Kun for serier. Bruk nettverks-IDer som Netflix 213 eller HBO 49.
+ 213 for Netflix
+ År
+ Bruk et firesifret år, f.eks. 2024.
+ Forhåndsvalg
+ Søk
+ Legg til kilde
+ Legg til Trakt-liste
+ Rediger Trakt-liste
+ Trakt-lister
+ Trakt-liste
+ Søk tittel, Trakt-URL eller liste-ID
+ Bruk en offentlig Trakt-liste-URL, numerisk liste-ID eller søk etter navn.
+ Weekend Watch, Award Winners
+ Søkeresultater
+ Populære lister
+ Populære lister
+ Retning
+ Stigende
+ Synkende
+ Liste-rekkefølge
+ Nylig lagt til
+ Tittel
+ Utgitt
+ Spilletid
+ Populær
+ Prosent
+ Stemmer
+ Action
+ Eventyr
+ Animasjon
+ Komedie
+ Skrekk
+ Sci-Fi
+ Drama
+ Krim
+ Reality
+ Engelsk
+ Koreansk
+ Japansk
+ Hindi
+ Spansk
+ USA
+ Korea
+ Japan
+ India
+ Storbritannia
+ Superhelt
+ Basert på roman
+ Tidsreise
+ Rom
+ Marvel
+ Disney
+ Pixar
+ Lucasfilm
+ Warner Bros.
+ Netflix
+ HBO
+ Disney+
+ Prime Video
+ Hulu
+ Original
+ Populær
+ Høyest rated
+ Nylig
+ Flest stemmer
+ Visningsregion
+ ISO 3166-1 landkode der tittelen er tilgjengelig. Eksempel: US, GB.
+ Hurtigregioner
+ Tjeneste-IDer
+ Bruk TMDB watch provider-IDer. Skill med komma (AND) eller pipe (OR).
+ 8|337|350
+ Hurtigtjenester
+ Netflix
+ Prime Video
+ Disney+
+ Apple TV+
+ Hulu
+ TMDB-liste
+ TMDB-film-samling
+ Produksjon
+ Nettverk
+ Person
+ Regissør
+ TMDB Discover
+ Opprett en for å organisere katalogene dine.
+ Ingen samlinger ennå
+ %1$d mappe(r)
+ Ingen elementer funnet
+ Mappe ikke funnet
+ Samlinger
+ Importer samlinger
+ JSON
+ Lim inn collections JSON nedenfor.
+ Importer
+ Ny samling
+ Festet
+ Alle
+ Dine samlinger
+ Laget med ❤️ av Tapframe og venner
+ Versjon %1$s (%2$s)
+ Av
+ På
+ Pause
+ Last inn på nytt
+ Har du allerede en konto?
+ Fortsett uten konto
+ Opprett konto
+ Har du ikke konto?
+ E-post
+ eller
+ Passord
+ Logg inn for å få tilgang til biblioteket og fremdriften din
+ Logg inn
+ Registrer deg for å synkronisere data på tvers av enheter
+ Registrer deg
+ Dataene dine vil kun lagres lokalt
+ Strøm alt, overalt
+ Velkommen tilbake
+ Bibliotek
+ Trakt-bibliotek
+ Hjem
+ Bibliotek
+ Profil
+ Søk
+ Lydspor
+ Lyd
+ Innebygd
+ Bunn-offset
+ Lukk avspiller
+ Farge
+ Spiller nå
+ E%1$d
+ S%1$dE%2$d
+ S%1$dE%2$d • %3$s
+ Episoder
+ Skriftstørrelse
+ %1$dsp
+ Lås avspillerkontroller
+ Ingen lydspor tilgjengelig
+ Ingen episoder tilgjengelig
+ Ingen strømmer funnet
+ Ingen
+ Omriss
+ Episoder
+ Kilder
+ Strømmer
+ Avspillingsfeil
+ Spiller
+ Trykk for å hente undertekster
+ Gå tilbake
+ Tilbakestill standard
+ Fyll
+ Tilpass
+ Zoom
+ Spol 10 sekunder bakover
+ -%1$ds
+ +%1$ds
+ -%1$ds
+ +%1$ds
+ Spol 10 sekunder fremover
+ Kilder
+ Stil
+ Tekst
+ Undertekster
+ Lysstyrke %1$s
+ Volum %1$s
+ Dempet
+ Lastet ned
+ Sendes
+ TBA
+ Trykk for å låse opp
+ Spor %1$d
+ Lås opp avspillerkontroller
+ Du ser på
+ Legg til profil
+ Tøm søk
+ Oppdag
+ Installerte tillegg klarte ikke å returnere gyldige søkeresultater.
+ Søk mislyktes
+ Installer og valider minst ett tillegg før du søker.
+ Ingen aktive tillegg
+ Installerte søkbare kataloger returnerte ingen treff for dette søket.
+ Ingen resultater funnet
+ Installerte tillegg eksponerer ikke katalogsøk.
+ Ingen søkbare kataloger
+ Søk filmer, serier…
+ Nylige søk
+ Fjern nylig søk
+ Om
+ Generelt
+ Konto
+ Tillegg
+ Utseende
+ Innhold & oppdagelse
+ Fortsett å se
+ Debrid
+ Hjemmeoppsett
+ Integrasjoner
+ Lisenser & attribusjon
+ MDBList-vurderinger
+ Detaljside
+ Varsler
+ Avspilling
+ Plugins
+ Plakatstil
+ Innstillinger
+ Støttespillere & bidragsytere
+ TMDB-berikelse
+ Trakt
+ OM
+ Konto- og synkroniseringsstatus
+ KONTO
+ Hjem-struktur og plakatstiler
+ Last ned nyeste versjon
+ Se etter oppdateringer
+ Administrer tillegg og oppdagelseskilder.
+ Administrer nedlastede filmer og episoder.
+ Nedlastinger
+ GENERELT
+ Administrer tilgjengelige integrasjoner
+ Administrer episodevarsler og send testvarsel.
+ Bytt til en annen profil.
+ Bytt profil
+ Åpne Trakt-tilkoblingsskjerm
+ Ingen innstillinger funnet.
+ Søk i innstillinger…
+ RESULTATER
+ APP-LISENS
+ DATA & TJENESTER
+ AVSPILLINGS-LISENS
+ Nuvio Mobile
+ Kildekode og lisensvilkår finnes i prosjektets repository.
+ Lisensiert under GNU General Public License v3.0.
+ The Movie Database (TMDB)
+ Nuvio bruker TMDB API for metadata, bilder, trailere, rollebesetning osv. Dette produktet bruker TMDB API men er ikke godkjent eller sertifisert av TMDB.
+ IMDb Non-Commercial Datasets
+ Nuvio bruker IMDb Non-Commercial Datasets for vurderinger. Informasjon fra IMDb[](https://www.imdb.com). Brukt med tillatelse.
+ Trakt
+ Nuvio kobler til Trakt for autentisering, seerhistorikk, synkronisering osv. Nuvio er ikke tilknyttet eller godkjent av Trakt.
+ MDBList
+ Nuvio bruker MDBList for eksterne vurderinger. Nuvio er ikke tilknyttet eller godkjent av MDBList.
+ IntroDB
+ Nuvio bruker IntroDB API for intro-, recap-, credits- og preview-tidsstempler. Nuvio er ikke tilknyttet eller godkjent av IntroDB.
+ MPVKit
+ Brukes for avspilling på iOS.
+ Lisensiert under LGPL v3.0.
+ AndroidX Media3 ExoPlayer 1.8.0
+ Brukes for avspilling på Android.
+ Lisensiert under Apache License, Version 2.0.
+ Laster Trakt-listene dine…
+ Velg hvor du vil lagre denne tittelen på Trakt
+ Doner
+ Gå til detaljer
+ Fjern
+ Start fra begynnelsen
+ Spill av
+ %1$d/10
+ Anmeldelse
+ Spoiler
+ Ingen Trakt-anmeldelser tilgjengelig ennå.
+ %1$d liker
+ Denne kommentaren inneholder spoilere.
+ Denne kommentaren inneholder spoilere og er skjult.
+ Kommentarer
+ Trailer
+ %1$s (%2$d)
+ Trailere
+ Ingen fullførte episoder
+ Ingen nedlastinger ennå
+ %1$d nedlastet episode(r)
+ Aktive
+ Filmer
+ Serier
+ Vis nedlastinger
+ Fullført • %1$s
+ Laster ned • %1$s
+ Mislyktes
+ Pauset • %1$s
+ Sett
+ Sesong %1$d
+ Spesialepisoder
+ Fortsett der du slapp
+ Legg til i bibliotek
+ Marker som usett
+ Marker som sett
+ Fjern fra bibliotek
+ Vis alle
+ Spill av manuelt
+ %1$s logo
+ Konto
+ Slett konto
+ Dette vil slette kontoen din og all tilknyttet data permanent.
+ Denne handlingen kan ikke angres. All data, profiler og synkroniseringshistorikk vil bli slettet permanent.
+ Slette konto?
+ E-post
+ Ikke innlogget
+ Logg ut
+ Du vil bli sendt tilbake til innloggingsskjermen.
+ Logge ut?
+ Status
+ Anonym
+ Innlogget
+ AMOLED Svart
+ Bruk ren svart bakgrunn for OLED-skjermer.
+ Appspråk
+ Velg språk
+ Innstillinger for Fortsett å se-seksjonen.
+ Liquid Glass
+ Bruk den innebygde iPhone-fanen i iOS 26 og nyere. Umiddelbar profilbytte fra fanelinjen er ikke tilgjengelig mens dette er på.
+ Juster kortbredde og hjørneradius.
+ VISNING
+ HJEM
+ TEMA
+ Samling • %1$s
+ Visningsnavn
+ Installer et tillegg med board-kompatible kataloger for å konfigurere hjemmerader.
+ Ingen hjemmekataloger
+ Hero-kilde
+ Skjult
+ Behold Hjem fokusert
+ %1$s • Grense nådd (maks %2$d)
+ Ingen hero-kilder valgt
+ Ikke i hero
+ Fjern fest til toppen fra samling for å flytte
+ Festet
+ Festet øverst
+ Endre rekkefølge
+ KATALOGER
+ KATALOGER & SAMLINGER
+ SAMLINGER
+ Hjemmeoppsett
+ Hero-kataloger
+ %1$d av %2$d valgt
+ Vis hero-seksjon
+ Vis hero-karusell øverst på hjemskjermen.
+ Skjul ikke-utgitt innhold
+ Skjul filmer og serier som ikke er utgitt ennå.
+ Skjul katalog-understrek
+ Fjern aksentlinjen under katalog- og samlingstitler.
+ %1$d av %2$d kataloger synlige • %3$d hero-kilder valgt
+ Åpne en katalog kun når du skal gi nytt navn eller endre rekkefølge.
+ Synlig
+ Skjul verdi
+ Avspiller, undertekster og auto-play
+ Hjørneradius
+ Plakatstil
+ Bredde
+ Tilpasset
+ Juster kortbredde og hjørneradius.
+ Skjul etiketter
+ Landskapsplakater
+ Forhåndsvisning
+ %1$s (%2$s)
+ Hjørneradius: %1$ddp
+ Høyde: %1$ddp
+ Bredde: %1$ddp
+ Klassisk
+ Pille
+ Avrundet
+ Skarp
+ Forsiktig
+ Balansert
+ Komfort
+ Kompakt
+ Tet
+ Stor
+ Standard
+ Vis verdi
+ Vis popup for å fortsette der du slapp ved oppstart.
+ Fortsett-prompt ved oppstart
+ Slør neste episode-miniature for å unngå spoilere.
+ Slør usette i Fortsett å se
+ Inkluder kommende episoder i Fortsett å se før de sendes.
+ Vis ikke-sendte neste episoder
+ SORTERINGSREKKefølge
+ Sorteringsrekkefølge
+ Standard
+ Sorter alle etter nyhet
+ Streaming-stil
+ Utgitt først, kommende til slutt
+ Plakatstil
+ VED OPPSTART
+ NESTE OPPFØRSEL
+ SYNLIGHET
+ Vis Fortsett å se-hylle på hjemskjermen.
+ Vis Fortsett å se
+ Plakat
+ Kunst-først plakatkort
+ Bred
+ Info-tett horisontalt kort
+ Vis neste episode basert på lengst sett episode.
+ Neste fra lengst sett episode
+ Foretrekk episode-miniatyrer når tilgjengelig.
+ Foretrekk episode-miniatyrer i Fortsett å se
+ HJEM
+ KILDER
+ Installer, fjern, oppdater og sorter innholdskilder.
+ Installer JavaScript-scraper-repositories og test providere internt.
+ Juster hjemmeoppsett, synlighet og plakat-oppførsel.
+ Innstillinger for detalj- og episodeskjermer.
+ Opprett egne kataloggrupperinger med mapper vist på hjem.
+ Integrasjoner
+ Metadata-berikelse-kontroller
+ Eksterne vurderingsleverandører
+ Eksperimentelle sky-konto-kilder
+ Debrid
+ Debrid-støtte er eksperimentell og kan endres eller fjernes senere.
+ Aktiver kilder
+ Vis spillbare resultater fra tilkoblede kontoer.
+ Legg til en API-nøkkel først.
+ Konto
+ Koble til Torbox-kontoen din.
+ Umiddelbar avspilling
+ Forbered lenker
+ Løs første kilder før avspilling starter.
+ Kilder å forberede
+ 1 kilde
+ %1$d kilder
+ Formatering
+ Navnemal
+ Styrer hvordan kildenavn vises.
+ Beskrivelsesmal
+ Styrer metadata vist under hver kilde.
+ API-nøkkel validert.
+ Kunne ikke validere denne API-nøkkelen.
+ Legg til MDBList API-nøkkel før du skrur på vurderinger.
+ Kreves for å hente vurderinger fra MDBList
+ API-nøkkel
+ API-nøkkel
+ Aktiver MDBList-vurderinger
+ Hent vurderinger fra eksterne leverandører på detaljsiden
+ API-nøkkel
+ Eksterne vurderingsleverandører
+ MDBList-vurderinger
+ Handlinger
+ Avspillings- og lagringskontroller.
+ Rollebesetning
+ Hovedrollebesetning.
+ Filmisk bakgrunn
+ Uskarp bakgrunnsbilde bak innhold.
+ Samling
+ Relatert samling eller franchise-rad.
+ Kommentarer
+ Anmeldelser fra Trakt
+ Detaljer
+ Spilletid, status, utgivelse, språk osv.
+ Episodekort
+ Velg hvordan episoder vises på metadata-skjermen.
+ Horisontal
+ Bakgrunnsstil radkort
+ Liste
+ Detalj-først stablede kort
+ Episoder
+ Sesonger og episode-liste for serier.
+ Slør usette episoder
+ Slør episode-miniatyrer til de er sett for å unngå spoilere.
+ Gruppe %1$d
+ Mer som dette
+ TMDB-anbefalinger på detaljsiden
+ Ingen
+ Oversikt
+ Sammendrag, vurderinger, sjangre og kjerne-credits.
+ Produksjon
+ Studioer og nettverk.
+ UTSEENDE
+ SEKSJONER
+ Fanegruppe %1$d
+ Faneoppsett
+ Grupper seksjoner i faner som TV-appen.
+ Trailere
+ Trailer-rad og avspillings-snarveier.
+ Varsler er for øyeblikket deaktivert i Nuvio.
+ Episodeutgivelsesvarsler
+ Send lokale varsler når en ny episode for en lagret serie blir tilgjengelig.
+ Systemvarsler er deaktivert for Nuvio. Skru dem på for å motta varsler.
+ %1$d utgivelsesvarsler er planlagt på denne enheten.
+ VARSler
+ TEST
+ Send testvarsel
+ Sender testvarsel…
+ Send et lokalt testvarsel for %1$s.
+ Lagre en serie i biblioteket ditt først for å teste varsler.
+ Testvarsel
+ Fellesskap
+ Se menneskene som bygger og støtter Nuvio på mobil, TV og web.
+ Supporters API er ikke konfigurert. Legg til DONATIONS_BASE_URL i local.properties.
+ Bidragsytere
+ Støttespillere
+ Åpne GitHub
+ GitHub-profil utilgjengelig
+ Ingen melding vedlagt.
+ Laster bidragsytere…
+ Laster støttespillere…
+ Kunne ikke laste bidragsytere
+ Kunne ikke laste støttespillere
+ Ingen bidragsytere funnet.
+ Ingen støttespillere funnet.
+ Kunne ikke laste bidragsytere.
+ Kunne ikke laste støttespillere.
+ Kunne ikke laste bidragsytere akkurat nå.
+ Kunne ikke laste støttespillere akkurat nå.
+ %1$d totale commits
+ Jan
+ Feb
+ Mar
+ Apr
+ Mai
+ Jun
+ Jul
+ Aug
+ Sep
+ Okt
+ Nov
+ Des
+ %1$s %2$s, %3$s
+ Alle installerte tillegg
+ Alle aktiverte plugins
+ Tillatte tillegg
+ Tillatte plugins
+ Anime Skip
+ AnimeSkip Client ID
+ Skriv inn din AnimeSkip API client ID. Hent en på anime-skip.com.
+ Aktiver intro-innsending
+ Vis en knapp for å sende intro/outro-tidsstempler til fellesskapsdatabasen.
+ IntroDB API-nøkkel
+ Skriv inn din IntroDB API-nøkkel for å sende tidsstempler.
+ Søk også i AnimeSkip etter hopp-tidsstempler (krever client ID).
+ Auto-spill neste episode
+ Start neste episode automatisk når prompt vises.
+ Kun enhets-dekodere
+ Foretrekk app-dekodere (FFmpeg)
+ Foretrekk enhets-dekodere
+ Dekoder-prioritet
+ Trykk utenfor for å lukke
+ Trykk utenfor for å lagre & lukke
+ %1$d dag
+ %1$d dager
+ %1$d time
+ %1$d timer
+ Bruk libass for ASS/SSA-undertekster
+ Eksperimentelt: avansert ASS/SSA-rendering (stiler, plassering, animasjoner)
+ Ekstern avspiller
+ Ekstern avspiller-app
+ Åpne ny avspilling med Androids standard videoapp eller velger.
+ Åpne ny avspilling med den valgte installerte spilleren.
+ Ingen støttede eksterne spillere installert
+ Hold Speed
+ Hold for å øke hastighet
+ Langtrykk hvor som helst på avspillerflaten for midlertidig hastighetsøkning.
+ Ugyldig regex-mønster
+ Varighet for siste lenke-cache
+ DV7 → HEVC-fallback
+ Kartlegg Dolby Vision Profile 7 til standard HEVC for enheter uten DV-støtte
+ Terskelminutter
+ Fallback når ingen outro-tidsstempel finnes.
+ %1$s min
+ Ingen elementer tilgjengelig
+ Ikke satt
+ Standard (mediefil)
+ Enhetsspråk
+ Tvungen
+ Ingen
+ Foretrekk binge-gruppe (neste episode)
+ Prøv samme kildeprofil først før vanlige auto-play-regler.
+ Foretrukket lydspråk
+ Foretrukket undertekst-språk
+ Forhåndsinnstillinger
+ Matcher mot strømnavn/tittel/beskrivelse/addon/url. Eksempel: 4K|2160p|Remux
+ Regex-mønster
+ Ingen mønster satt. Eksempel: 4K|2160p|Remux
+ Alle 1080p+
+ AVC / x264
+ BluRay-kvalitet
+ Dolby Atmos / DTS
+ Engelsk
+ HDR / Dolby Vision
+ HEVC / x265
+ Ingen CAM/TS
+ Ingen REMUX/HDR
+ 1080p Standard
+ 4K / Remux
+ 720p / Mindre
+ WEB-kilder
+ Libass render-modus
+ Standard cues
+ Effects Canvas
+ Effects OpenGL
+ Overlay Canvas
+ Overlay OpenGL (anbefalt)
+ Gjenbruk siste lenke
+ Auto-spill siste fungerende strøm for samme film/episode hvis cachen fortsatt er gyldig
+ Sekundært lydspråk
+ Sekundært foretrukket språk
+ DEKODER
+ NESTE EPISODE
+ AVSPILLER
+ HOPP SEGMENTER
+ STRØM AUTO-PLAY
+ STRØMVALG
+ UNDERTEKST OG LYD
+ UNDERTEKST-RENDERING
+ %1$d valgt
+ Laste-overlay
+ Vis lasts skjerm til første bilde vises.
+ Hopp intro
+ Bruk introdb.app for å oppdage introer og recaps.
+ Auto-play kildeomfang
+ Alle installerte tillegg
+ Auto-play vurderer kun strømmer fra installerte tillegg.
+ Alle kilder
+ Auto-play kan bruke både installerte tillegg og aktiverte plugins.
+ Kun aktiverte plugins
+ Auto-play vurderer kun strømmer fra aktiverte plugins.
+ Kun installerte tillegg
+ Auto-play vurderer kun strømmer fra installerte tillegg.
+ Auto-strømvalg
+ Auto-spill første kilde
+ Spill første tilgjengelige kilde automatisk.
+ Manuell (velg kilde)
+ Vis alltid kildeliste og la meg velge.
+ Auto-play regex-treff
+ Spill første kilde som matcher regex-mønsteret ditt.
+ Tidsfrist for strømvalg
+ Ventetid for tillegg før valg.
+ Terskelminutter
+ Terskelmodus for neste episode
+ Minutter før slutt
+ Prosent
+ Terskelprosent
+ Fallback når ingen outro-tidsstempel finnes.
+ %1$s%
+ Umiddelbart
+ %1$ss
+ Ubegrenset
+ Tunneled avspilling
+ Maskinvarenivå lyd/video-synk. Kan forbedre avspilling på noen Android TV-enheter.
+ Legg til din egen TMDB API-nøkkel før du skrur på berikelse.
+ API-nøkkel
+ Aktiver TMDB-berikelse
+ Bruk TMDB som metadata-kilde for å forbedre tilleggsdata
+ Skriv inn din TMDB v3 API-nøkkel.
+ Språkkode
+ Grafikk
+ Logo og bakgrunnsbilder fra TMDB
+ Grunninfo
+ Beskrivelse, sjangre og rating fra TMDB
+ Samlinger
+ TMDB film-samlinger i utgivelsesrekkefølge
+ Credits
+ Rollebesetning med bilder, regissør og manusforfatter fra TMDB
+ Detaljer
+ Spilletid, status, land og språk fra TMDB
+ Episoder
+ Episodetitler, sammendrag, miniatyrer og spilletid fra TMDB
+ Mer som dette
+ TMDB-anbefalinger på detaljsiden
+ Nettverk
+ Nettverk med logoer fra TMDB
+ Produksjonsselskaper
+ Produksjonsselskaper fra TMDB
+ Sesongplakater
+ Bruk TMDB sesongplakater i metadata-skjermen for serier.
+ Trailere
+ Trailer-kandidater fra TMDB videoer
+ Personlig API-nøkkel
+ Språk
+ TMDB metadata-språk for tittel, logo og aktiverte felt
+ LEGITIMASJONER
+ LOKALISERING
+ MODULER
+ TMDB-berikelse
+ Etter godkjenning blir du automatisk sendt tilbake.
+ AUTENTISERING
+ Kommentarer
+ Vis Trakt-anmeldelser på metadata-sider
+ Koble til Trakt
+ Tilkoblet som %1$s
+ Trakt-bruker
+ Koble fra
+ Kunne ikke åpne nettleser
+ FUNKSJONER
+ Fullfør Trakt-innlogging i nettleseren din
+ Synkroniser seerliste, fremdrift, Fortsett å se, scrobbles og personlige lister med Trakt.
+ Mangler Trakt-legitimasjon i local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).
+ Åpne Trakt-innlogging
+ Lagre-handlinger kan nå målrette Trakt watchlist og personlige lister.
+ Logg inn med Trakt for å aktivere listebasert lagring og Trakt-bibliotekmodus.
+ Bibliotekkilde
+ Velg hvilket bibliotek som skal brukes for lagring og visning av samlingen din
+ Bibliotekkilde
+ Velg hvor du vil lagre og administrere bibliotekelementer
+ Trakt
+ Nuvio-bibliotek
+ Trakt-bibliotek valgt
+ Nuvio-bibliotek valgt
+ Seer-fremdrift
+ Velg hvilken fremdriftskilde som driver resume og Fortsett å se
+ Seer-fremdrift
+ Velg om resume og Fortsett å se skal bruke Trakt eller Nuvio Sync mens Trakt scrobbling er aktiv.
+ Trakt
+ Nuvio Sync
+ Seer-fremdrift satt til Trakt
+ Seer-fremdrift satt til Nuvio Sync
+ Fortsett å se-vindu
+ Trakt-historikk som vurderes for Fortsett å se
+ Fortsett å se-vindu
+ Velg hvor mye Trakt-aktivitet som skal vises i Fortsett å se.
+ All historikk
+ %1$d dager
+ Publikumspoeng
+ IMDb
+ Letterboxd
+ Metacritic
+ Rotten Tomatoes
+ TMDB
+ Trakt
+ Ukjent
+ Amber
+ Crimson
+ Emerald
+ Ocean
+ Rose
+ Violet
+ Hvit
+ Neste episode
+ Finner kilde…
+ Spiller via %1$s om %2$d…
+ Neste episode miniatyr
+ Ikke sendt
+ Hopp over
+ Hopp over intro
+ Hopp over outro
+ Hopp over recap
+ Ingen undertekster funnet
+ Afrikaans
+ Albansk
+ Amharisk
+ Arabisk
+ Armensk
+ Aserbajdsjansk
+ Baskisk
+ Hviterussisk
+ Bengali
+ Bosnisk
+ Bulgarsk
+ Burmesisk
+ Katalansk
+ Kinesisk
+ Kinesisk (forenklet)
+ Kinesisk (tradisjonell)
+ Kroatisk
+ Tsjekkisk
+ Dansk
+ Nederlandsk
+ Engelsk
+ Estisk
+ Filippinsk
+ Finsk
+ Fransk
+ Galisisk
+ Georgisk
+ Tysk
+ Gresk
+ Gujarati
+ Hebraisk
+ Hindi
+ Ungarsk
+ Islandsk
+ Indonesisk
+ Irsk
+ Italiensk
+ Japansk
+ Kannada
+ Kasakhisk
+ Khmer
+ Koreansk
+ Laotisk
+ Latvisk
+ Litauisk
+ Makedonsk
+ Malayisk
+ Malayalam
+ Maltesisk
+ Marathi
+ Mongolsk
+ Nepali
+ Norsk
+ Persisk
+ Polsk
+ Portugisisk (Portugal)
+ Portugisisk (Brasil)
+ Punjabi
+ Rumensk
+ Russisk
+ Serbisk
+ Singalesisk
+ Slovakisk
+ Slovensk
+ Spansk
+ Spansk (Latin-Amerika)
+ Swahili
+ Svensk
+ Tamil
+ Telugu
+ Thai
+ Tyrkisk
+ Ukrainsk
+ Urdu
+ Usbekisk
+ Vietnamesisk
+ Walisisk
+ Zulu
+ Tøm
+ Fortsett
+ Ignorer
+ Installer
+ Senere
+ Nei
+ Oppdater
+ Ja
+ Vil du avslutte appen?
+ Avslutt app
+ Denne katalogen returnerte ingen elementer.
+ Ingen titler funnet
+ Sjekk Wi-Fi eller mobildata og prøv igjen.
+ Regissør
+ Kunne ikke laste
+ Mer som dette
+ Sesonger
+ Dette tillegget returnerte videoer for serien, men ingen inkluderte sesong- eller episodenummer.
+ Dette tillegget ga ikke episode-metadata for denne serien.
+ Episoder er ikke publisert av dette tillegget ennå.
+ Enheten er online, men Nuvio kunne ikke nå nødvendige servere.
+ Vis mindre
+ Vis mer ▾
+ Manusforfatter
+ Alle sjangre
+ Katalog
+ %1$s • %2$s
+ Den valgte katalogen klarte ikke å returnere oppdagelseselementer.
+ Kunne ikke laste oppdagelse
+ Installerte tillegg eksponerer ikke board-kompatible kataloger for oppdagelse.
+ Ingen oppdagelseskataloger
+ Den valgte katalogen og filtrene returnerte ingen elementer.
+ Ingen titler funnet
+ Installer og valider minst ett tillegg før du blar i oppdagelseskataloger.
+ Velg katalog
+ Velg sjanger
+ Velg type
+ Type
+ Marker tidligere som usett
+ Marker tidligere som sett
+ Marker %1$s som usett
+ Marker %1$s som sett
+ Marker som usett
+ Marker som sett
+ Neste
+ %1$s sett
+ Installer og valider minst ett tillegg før lasting av katalograder på hjem.
+ Installerte tillegg eksponerer for øyeblikket ikke board-kompatible kataloger uten nødvendige ekstraer.
+ Ingen hjemmerader tilgjengelig
+ Vis detaljer
+ Avspillings- og lagringskontroller.
+ Handlinger
+ Hovedrollebesetning.
+ Relatert samling eller franchise-rad.
+ Samling
+ Trakt-kommentarseksjon.
+ Spilletid, status, utgivelse, språk og relatert info.
+ Detaljer
+ Sesonger og episode-liste for serier.
+ Anbefalingsrad.
+ Mer som dette
+ Sammendrag, vurderinger, sjangre og kjerne-credits.
+ Oversikt
+ Studioer og nettverk.
+ Produksjon
+ Trailer-rad og avspillings-snarveier.
+ Tilbake online
+ Kan ikke nå servere
+ Ingen internettforbindelse
+ (alder %1$d)
+ Født %1$s%2$s
+ Døde %1$s
+ Kjent for: %1$s
+ Nyeste
+ Kunne ikke laste detaljer for %1$s
+ Populær
+ Noe gikk galt
+ Kommende
+ Slett
+ Avbryt
+ Skriv PIN
+ Skriv PIN for %1$s
+ Glemt PIN?
+ Feil PIN
+ Låst. Prøv igjen om %1$ds
+ Avatar-alternativer vises her når katalogen er lastet.
+ Avatar: %1$s
+ Skriv inn en gyldig http:// eller https:// bilde-URL.
+ Velg en avatar
+ Velg en avatar nedenfor.
+ Opprett profil
+ Tilpasset avatar-URL valgt.
+ Tilpasset avatar-URL
+ Lim inn en bilde-lenke, eller la den være tom for å bruke innebygd avatar-katalog.
+ https://example.com/avatar.png
+ All data for «%1$s» vil bli slettet permanent.
+ Slett profil
+ Legg til profil
+ Rediger profil
+ Skriv nåværende PIN
+ Skriv ny PIN
+ Profil %1$d
+ Laster avatarer…
+ Administrer profiler
+ Profilnavn
+ Ny profil
+ Primære tillegg av
+ Primære tillegg på
+ Fjern PIN for %1$s
+ Fjern PIN-lås
+ Lagrer…
+ Sikkerhet
+ Legg til en PIN hvis du vil låse denne profilen før bytte.
+ Denne profilen er beskyttet med PIN.
+ Velg en avatar for denne profilen.
+ Sett PIN-lås
+ Navnløs profil
+ Bruk primære tillegg
+ Del hovedprofilens tilleggsoppsett i stedet for å administrere en egen liste.
+ Hvem ser?
+ Lastet ned
+ Gjenoppta
+ Aktive scrapere
+ Sjekker flere tillegg…
+ Kopier strøm-lenke
+ Last ned fil
+ Åpne i ekstern avspiller
+ Åpne i intern avspiller
+ De installerte strøm-tilleggene klarte ikke å returnere et gyldig svar.
+ Kunne ikke laste strømmer
+ Installer et tillegg først for å laste strømmer for denne tittelen.
+ Installerte tillegg gir ikke strømmer for denne typen tittel.
+ Ingen strøm-tillegg tilgjengelig
+ Ingen av de installerte tilleggene returnerte strømmer for denne tittelen.
+ S%1$d E%2$d
+ Episode
+ S%1$dE%2$d - %3$s
+ Henter…
+ Finner kilde…
+ Finner strømmer…
+ Strøm-lenke kopiert
+ Ingen direkte strøm-lenke tilgjengelig
+ Ingen metadata tilgjengelig
+ Oppdater strømmer
+ Gjenoppta fra %1$d%
+ Gjenoppta fra %1$s
+ STØRRELSE %1$s
+ Denne strømtypen støttes ikke
+ Legg til en Debrid API-nøkkel i Innstillinger.
+ Dette Debrid-resultatet er utgått. Oppdaterer strømmer.
+ Kunne ikke løse denne Debrid-strømmen.
+ Kunne ikke åpne ekstern avspiller
+ Velg en ekstern avspiller i innstillinger først
+ Ingen ekstern avspiller er tilgjengelig
+ Lukk trailer
+ Kan ikke spille trailer
+ Kunne ikke laste Trakt-lister
+ Kunne ikke oppdatere Trakt-lister
+ %1$s • %2$s
+ Oppdateringssjekk mislyktes
+ Nedlasting mislyktes
+ Laster ned %1$d%
+ Kunne ikke starte installasjon
+ Du bruker nyeste versjon.
+ Skru på app-installasjoner for Nuvio, og kom tilbake.
+ Laster ned oppdatering…
+ Ingen oppdateringer funnet.
+ En ny versjon er klar til installasjon.
+ In-app oppdateringer er ikke tilgjengelig på denne builden.
+ Forbereder nedlasting
+ Versjonsnotater
+ Tillat installasjoner for å fortsette
+ Oppdatering tilgjengelig
+ Oppdateringsstatus
+ Dette tillegget er allerede installert.
+ Skriv inn en gyldig tilleggs-URL
+ Kunne ikke laste manifest
+ Nuvio
+ Konto-sletting mislyktes
+ Innlogging mislyktes
+ Utlogging mislyktes
+ Registrering mislyktes
+ Kunne ikke laste katalogelementer.
+ Neste
+ Neste • S%1$dE%2$d
+ %1$s logo
+ Kunne ikke laste kommentarer
+ Kunne ikke laste detaljer fra noen tillegg.
+ Nettverk
+ Ingen tillegg gir meta for dette innholdet.
+ Nedlasting mislyktes
+ Viser live nedlastingsfremdrift og kontroller.
+ Nedlastinger
+ Nedlasting fullført
+ Laster ned %1$s • %2$s
+ Laster ned %1$s • %2$s / %3$s
+ Nedlasting mislyktes
+ Pauset %1$s
+ Fjern
+ Fjern %1$s fra %2$s?
+ Fjern %1$s fra biblioteket ditt?
+ Fjern fra bibliotek?
+ Film
+ Serie
+ Varsler når en ny episode for en lagret serie er ute.
+ Forhåndsvisning av episodeutgivelsesvarsel.
+ Kunne ikke sende testvarsel.
+ Testvarsel sendt for %1$s.
+ Kan ikke spille denne strømmen.
+ Denne profil-PIN-en er endret. Koble til én gang for å oppdatere låsen på denne enheten.
+ Kunne ikke fjerne PIN-lås. Prøv igjen.
+ Koble til internett for å fjerne PIN-lås.
+ Denne PIN-en kan ikke verifiseres offline på denne enheten ennå. Koble til og lås opp online først.
+ Kunne ikke sette PIN. Prøv igjen.
+ Koble til internett for å sette PIN.
+ Denne profilen bruker primære tillegg.
+ Kunne ikke laste %1$s
+ Strøm
+ Innebygd
+ Autorisasjon nektet
+ Fullfør Trakt-innlogging i nettleseren din
+ Ugyldig Trakt-callback
+ Ugyldig Trakt-callback-tilstand
+ Ugyldig Trakt token-svar
+ Kunne ikke laste Trakt-bibliotek
+ Liste %1$d
+ Trakt returnerte ikke en autorisasjonskode
+ Mangler Trakt-legitimasjon
+ Kunne ikke laste Trakt-fremdrift
+ Kunne ikke fullføre Trakt-innlogging
+ Trakt-bruker
+ Seerliste
+ Trailer
+ Ukjent
+ Tillegg
+ Lagret
+ Spill %1$s
+ Gjenoppta %1$s
+ JSON er tom.
+ Samling %1$d har tom ID.
+ Samling «%1$s» har tom tittel.
+ Mappe %1$d i «%2$s» har tom ID.
+ Mappe «%1$s» i «%2$s» har tom tittel.
+ Kilde %1$d i mappe «%2$s» har tomme felt.
+ Kilde %1$d i mappe «%2$s» mangler Trakt liste-ID.
+ Ugyldig JSON: %1$s
+ Tillegg ikke funnet: %1$s
+ Januar
+ Februar
+ Mars
+ April
+ Mai
+ Juni
+ Juli
+ August
+ September
+ Oktober
+ November
+ Desember
+ Jan
+ Feb
+ Mar
+ Apr
+ Mai
+ Jun
+ Jul
+ Aug
+ Sep
+ Okt
+ Nov
+ Des
+ Produksjonsselskap
+ Nettverk
+ Kunne ikke laste %1$s
+ Populær
+ Nylig
+ %1$s • %2$s
+ Høyest rated
+ Aldersgrense
+ Filmdetaljer
+ Originalspråk
+ Opprinnelsesland
+ Utgivelsesinfo
+ Spilletid
+ Plakater
+ Tekst
+ Seriedetaljer
+ Status
+ Videoer
+ FIL
+ Ingen direkte strøm-lenke tilgjengelig
+ Erstattet tidligere nedlasting
+ Nedlasting startet
+ Ikke støttet strømformat for nedlastinger
+ Tom svarbody
+ Forespørsel mislyktes med HTTP %1$d
+ Nedlastingssystemet er ikke initialisert
+ Nedlastingsforespørsel mislyktes
+ %1$s - %2$s
+ Lagrede titler vil dukke opp her etter at du trykker Lagre på en detaljskjerm.
+ Biblioteket ditt er tomt
+ Kunne ikke laste bibliotek
+ Annet
+ Bibliotek
+ Koble til Trakt og lagre titler til watchlist eller personlige lister.
+ Trakt-biblioteket ditt er tomt
+ Kunne ikke laste Trakt-bibliotek
+ Trakt-bibliotek
+ Anime
+ Kanaler
+ Filmer
+ Serier
+ TV
+ %1$s er ute nå
+ %1$s • %2$s er ute nå
+ En ny episode er ute nå
+ %1$s er ute nå
+ Episodeutgivelser
+ Alkohol/Rus
+ Skremmende
+ Nakenhet
+ Banning
+ Mild
+ Moderat
+ Alvorlig
+ Vold
+ Skaper
+ Regissør
+ Manusforfatter
+ Publikumspoeng
+ Ingen spillbar trailer-strøm funnet.
+ Sesong %1$d - %2$s
+ B
+ KB
+ MB
+ GB
+
\ No newline at end of file
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 955e47cd..ddbb9b65 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.
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