diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index e4e5c4d6..94036653 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -44,6 +44,7 @@ import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage +import com.nuvio.app.features.streams.BingeGroupCacheStorage import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.ResumePromptStorage @@ -87,6 +88,7 @@ class MainActivity : AppCompatActivity() { EpisodeReleaseNotificationsStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext) + BingeGroupCacheStorage.initialize(applicationContext) PluginStorage.initialize(applicationContext) CollectionMobileSettingsStorage.initialize(applicationContext) CollectionStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index 2ae1bccc..d1ff44e5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -19,6 +19,13 @@ actual object DebridSettingsStorage { private const val torboxApiKeyKey = "debrid_torbox_api_key" private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" + private const val streamMaxResultsKey = "debrid_stream_max_results" + private const val streamSortModeKey = "debrid_stream_sort_mode" + private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" + private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" + private const val streamHdrFilterKey = "debrid_stream_hdr_filter" + private const val streamCodecFilterKey = "debrid_stream_codec_filter" + private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" private val syncKeys = listOf( @@ -26,6 +33,13 @@ actual object DebridSettingsStorage { torboxApiKeyKey, realDebridApiKeyKey, instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, streamNameTemplateKey, streamDescriptionTemplateKey, ) @@ -60,6 +74,48 @@ actual object DebridSettingsStorage { saveInt(instantPlaybackPreparationLimitKey, limit) } + actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) + + actual fun saveStreamMaxResults(maxResults: Int) { + saveInt(streamMaxResultsKey, maxResults) + } + + actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) + + actual fun saveStreamSortMode(mode: String) { + saveString(streamSortModeKey, mode) + } + + actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) + + actual fun saveStreamMinimumQuality(quality: String) { + saveString(streamMinimumQualityKey, quality) + } + + actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) + + actual fun saveStreamDolbyVisionFilter(filter: String) { + saveString(streamDolbyVisionFilterKey, filter) + } + + actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) + + actual fun saveStreamHdrFilter(filter: String) { + saveString(streamHdrFilterKey, filter) + } + + actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) + + actual fun saveStreamCodecFilter(filter: String) { + saveString(streamCodecFilterKey, filter) + } + + actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) + + actual fun saveStreamPreferences(preferences: String) { + saveString(streamPreferencesKey, preferences) + } + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) actual fun saveStreamNameTemplate(template: String) { @@ -121,6 +177,13 @@ actual object DebridSettingsStorage { loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } + loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } + loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } + loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } + loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } + loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } + loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } + loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } } @@ -134,6 +197,13 @@ actual object DebridSettingsStorage { payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) + payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) + payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) + payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) + payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) + payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) + payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 5cb861a8..743c27f8 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -51,11 +51,26 @@ actual object PlayerSettingsStorage { private const val introSubmitEnabledKey = "intro_submit_enabled" private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" + private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_binge_group" private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2" private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" private const val useLibassKey = "use_libass" private const val libassRenderTypeKey = "libass_render_type" + private const val iosVideoOutputPresetKey = "ios_video_output_preset" + private const val iosToneMappingModeKey = "ios_tone_mapping_mode" + private const val iosTargetPrimariesKey = "ios_target_primaries" + private const val iosTargetTransferKey = "ios_target_transfer" + private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" + private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" + private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" + private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" + private const val iosDebandEnabledKey = "ios_deband_enabled" + private const val iosInterpolationEnabledKey = "ios_interpolation_enabled" + private const val iosBrightnessKey = "ios_brightness" + private const val iosContrastKey = "ios_contrast" + private const val iosSaturationKey = "ios_saturation" + private const val iosGammaKey = "ios_gamma" private val syncKeys = listOf( showLoadingOverlayKey, resizeModeKey, @@ -87,11 +102,26 @@ actual object PlayerSettingsStorage { animeSkipClientIdKey, streamAutoPlayNextEpisodeEnabledKey, streamAutoPlayPreferBingeGroupKey, + streamAutoPlayReuseBingeGroupKey, nextEpisodeThresholdModeKey, nextEpisodeThresholdPercentKey, nextEpisodeThresholdMinutesBeforeEndKey, useLibassKey, libassRenderTypeKey, + iosVideoOutputPresetKey, + iosToneMappingModeKey, + iosTargetPrimariesKey, + iosTargetTransferKey, + iosHardwareDecoderModeKey, + iosExtendedDynamicRangeEnabledKey, + iosTargetColorspaceHintEnabledKey, + iosHdrComputePeakEnabledKey, + iosDebandEnabledKey, + iosInterpolationEnabledKey, + iosBrightnessKey, + iosContrastKey, + iosSaturationKey, + iosGammaKey, ) private var preferences: SharedPreferences? = null @@ -581,6 +611,23 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, true) + } else { + null + } + } + + actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey), enabled) + ?.apply() + } + actual fun loadNextEpisodeThresholdMode(): String? = preferences?.getString(ProfileScopedKey.of(nextEpisodeThresholdModeKey), null) @@ -652,6 +699,120 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadIosVideoOutputPreset(): String? = + preferences?.getString(ProfileScopedKey.of(iosVideoOutputPresetKey), null) + + actual fun saveIosVideoOutputPreset(preset: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosVideoOutputPresetKey), preset)?.apply() + } + + actual fun loadIosToneMappingMode(): String? = + preferences?.getString(ProfileScopedKey.of(iosToneMappingModeKey), null) + + actual fun saveIosToneMappingMode(mode: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosToneMappingModeKey), mode)?.apply() + } + + actual fun loadIosTargetPrimaries(): String? = + preferences?.getString(ProfileScopedKey.of(iosTargetPrimariesKey), null) + + actual fun saveIosTargetPrimaries(primaries: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosTargetPrimariesKey), primaries)?.apply() + } + + actual fun loadIosTargetTransfer(): String? = + preferences?.getString(ProfileScopedKey.of(iosTargetTransferKey), null) + + actual fun saveIosTargetTransfer(transfer: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosTargetTransferKey), transfer)?.apply() + } + + actual fun loadIosHardwareDecoderMode(): String? = + preferences?.getString(ProfileScopedKey.of(iosHardwareDecoderModeKey), null) + + actual fun saveIosHardwareDecoderMode(mode: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosHardwareDecoderModeKey), mode)?.apply() + } + + private fun loadIosBoolean(keyBase: String, defaultValue: Boolean): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(keyBase) + if (sharedPreferences.contains(key)) sharedPreferences.getBoolean(key, defaultValue) else null + } + + private fun saveIosBoolean(keyBase: String, enabled: Boolean) { + preferences?.edit()?.putBoolean(ProfileScopedKey.of(keyBase), enabled)?.apply() + } + + private fun loadIosInt(keyBase: String): Int? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(keyBase) + if (sharedPreferences.contains(key)) sharedPreferences.getInt(key, 0) else null + } + + private fun saveIosInt(keyBase: String, value: Int) { + preferences?.edit()?.putInt(ProfileScopedKey.of(keyBase), value)?.apply() + } + + actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? = + loadIosBoolean(iosExtendedDynamicRangeEnabledKey, true) + + actual fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) { + saveIosBoolean(iosExtendedDynamicRangeEnabledKey, enabled) + } + + actual fun loadIosTargetColorspaceHintEnabled(): Boolean? = + loadIosBoolean(iosTargetColorspaceHintEnabledKey, true) + + actual fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) { + saveIosBoolean(iosTargetColorspaceHintEnabledKey, enabled) + } + + actual fun loadIosHdrComputePeakEnabled(): Boolean? = + loadIosBoolean(iosHdrComputePeakEnabledKey, true) + + actual fun saveIosHdrComputePeakEnabled(enabled: Boolean) { + saveIosBoolean(iosHdrComputePeakEnabledKey, enabled) + } + + actual fun loadIosDebandEnabled(): Boolean? = + loadIosBoolean(iosDebandEnabledKey, false) + + actual fun saveIosDebandEnabled(enabled: Boolean) { + saveIosBoolean(iosDebandEnabledKey, enabled) + } + + actual fun loadIosInterpolationEnabled(): Boolean? = + loadIosBoolean(iosInterpolationEnabledKey, false) + + actual fun saveIosInterpolationEnabled(enabled: Boolean) { + saveIosBoolean(iosInterpolationEnabledKey, enabled) + } + + actual fun loadIosBrightness(): Int? = loadIosInt(iosBrightnessKey) + + actual fun saveIosBrightness(value: Int) { + saveIosInt(iosBrightnessKey, value) + } + + actual fun loadIosContrast(): Int? = loadIosInt(iosContrastKey) + + actual fun saveIosContrast(value: Int) { + saveIosInt(iosContrastKey, value) + } + + actual fun loadIosSaturation(): Int? = loadIosInt(iosSaturationKey) + + actual fun saveIosSaturation(value: Int) { + saveIosInt(iosSaturationKey, value) + } + + actual fun loadIosGamma(): Int? = loadIosInt(iosGammaKey) + + actual fun saveIosGamma(value: Int) { + saveIosInt(iosGammaKey, value) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } @@ -683,11 +844,26 @@ actual object PlayerSettingsStorage { loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) } loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } + loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) } + loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) } + loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } + loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } + loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } + loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } + loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } + loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } + loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) } + loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) } + loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) } + loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) } + loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) } + loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -727,10 +903,25 @@ actual object PlayerSettingsStorage { payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled) payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) + payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) + payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset) + payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode) + payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) + payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) + payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) + payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) + payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) + payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) + payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled) + payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled) + payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness) + payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast) + payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation) + payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt new file mode 100644 index 00000000..253c3594 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt @@ -0,0 +1,32 @@ +package com.nuvio.app.features.streams + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +actual object BingeGroupCacheStorage { + private const val preferencesName = "nuvio_binge_group_cache" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun load(hashedKey: String): String? = + preferences?.getString(ProfileScopedKey.of(hashedKey), null) + + actual fun save(hashedKey: String, value: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(hashedKey), value) + ?.apply() + } + + actual fun remove(hashedKey: String) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(hashedKey)) + ?.apply() + } +} diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml index 4a1efbc3..06b43610 100644 --- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml @@ -1331,4 +1331,4 @@ KB MB GB - \ No newline at end of file + diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 00af8afd..330c2cd6 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1,4 +1,5 @@ + Źródła danych, podziękowania i licencje platformy Osoby wspierające i współtworzące projekt Wstecz Anuluj @@ -17,6 +18,8 @@ Wznów Ponów Zapisz + Zapisywanie… + Sprawdź Instalowanie Dodatki Aktywny @@ -110,29 +113,38 @@ Produkcja Stacja Kolekcja + Osoba + Reżyser Niestandardowy Wybierz gotowe źródło. Możesz je edytować lub usunąć po dodaniu. Wklej publiczny URL listy TMDB lub sam numer z URL. Wyszukaj po nazwie studia lub wklej ID/URL firmy TMDB i dodaj bezpośrednio. Wprowadź ID stacji. Popularne stacje są dostępne w szablonach i filtrach. Wyszukaj nazwę kolekcji filmów lub wklej ID kolekcji z TMDB. + Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii aktora. + Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii reżysera. Zbuduj dynamiczny wiersz TMDB z opcjonalnymi filtrami. Zostaw pola puste, gdy nie potrzebujesz danego filtra. Publiczna lista TMDB ID stacji ID kolekcji + ID osoby Nazwa firmy produkcyjnej, ID lub URL ID lub URL TMDB https://www.themoviedb.org/list/8504994 lub 8504994 213 dla Netflix, 49 dla HBO, 2739 dla Disney+ 10 dla kolekcji Star Wars Marvel Studios, 420 lub URL firmy + 31 dla Toma Hanksa lub URL osoby Przykłady: Marvel Studios, 420 lub https://www.themoviedb.org/company/420. Przykład: Star Wars Collection, Harry Potter Collection lub URL kolekcji. Przykładowe ID: Netflix 213, HBO 49, Disney+ 2739. Przykład: https://www.themoviedb.org/list/8504994 lub 8504994. + Przykład: https://www.themoviedb.org/person/31-tom-hanks lub 31. Wyświetlany tytuł Wyświetlany jako nazwa wiersza/karty. Jeśli pusty, Nuvio utworzy go ze źródła. Filmy Marvela, Oryginały Netflix, Pixar + Filmy Toma Hanksa, Ulubieni aktorzy + Filmy Christophera Nolana, Ulubieni reżyserzy Najlepsze filmy akcji, Koreańskie dramy, Animacja 2024 Wyniki wyszukiwania Kolekcja TMDB @@ -179,6 +191,27 @@ Szablony Szukaj Dodaj źródło + Dodaj listę Trakt + Edytuj listę Trakt + Listy Trakt + Lista Trakt + Szukaj tytułu, URL Trakt lub ID listy + Użyj publicznego URL listy Trakt, numerycznego ID listy lub wyszukaj po nazwie. + Weekendowe filmy, Laureaci nagród + Wyniki wyszukiwania + Popularne teraz + Popularne listy + Kierunek + Rosnąco + Malejąco + Kolejność listy + Ostatnio dodane + Tytuł + Data premiery + Czas trwania + Popularne + Procent + Głosy Akcja Przygodowy Animacja @@ -212,13 +245,29 @@ Disney+ Prime Video Hulu + Oryginalna Popularne Najwyżej oceniane Ostatnie + Najczęściej głosowane + Region dostępności + Kod kraju ISO 3166-1, w którym tytuł jest dostępny. Przykład: US, GB. + Popularne regiony dostępności + ID platform streamingowych + Użyj ID platform TMDB. Oddziel wiele przecinkami dla AND lub pionowymi kreskami dla OR. + 8|337|350 + Popularne platformy streamingowe + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu Lista TMDB Kolekcja filmów TMDB Produkcja Stacja + Osoba + Reżyser TMDB Discover Utwórz jedną, aby uporządkować katalogi. Brak kolekcji @@ -331,8 +380,10 @@ Wygląd Treści i odkrywanie Kontynuuj oglądanie + Debrid Ekran główny Integracje + Licencje i atrybucje Oceny MDBList Ekran metadanych Powiadomienia @@ -358,6 +409,31 @@ Przełącz na inny profil. Przełącz profil Połącz Trakt, synchronizuj listy obserwowanych i zapisuj tytuły bezpośrednio w Trakt. + Nie znaleziono ustawień. + Szukaj ustawień... + WYNIKI + LICENCJA APLIKACJI + DANE I USŁUGI + LICENCJA ODTWARZANIA + Nuvio Mobile + Kod źródłowy i warunki licencji są dostępne w repozytorium projektu. + Licencjonowany na podstawie GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio korzysta z API TMDB do metadanych filmów i seriali, grafik, zwiastunów, obsady, szczegółów produkcji, kolekcji i rekomendacji. Ten produkt korzysta z API TMDB, ale nie jest wspierany ani certyfikowany przez TMDB. + Niekomercyjne zbiory danych IMDb + Nuvio korzysta z niekomercyjnych zbiorów danych IMDb, w tym title.ratings.tsv.gz, do ocen i liczby głosów IMDb. Informacje dzięki uprzejmości IMDb (https://www.imdb.com). Wykorzystywane za zgodą. Dane IMDb służą do użytku osobistego i niekomercyjnego zgodnie z warunkami IMDb. + Trakt + Nuvio łączy się z Trakt w celu uwierzytelniania konta, historii oglądania, synchronizacji postępu, danych biblioteki, ocen, list i komentarzy. Nuvio nie jest powiązane z Trakt ani przez nie wspierane. + MDBList + Nuvio korzysta z MDBList do ocen i danych zewnętrznych dostawców ocen. Nuvio nie jest powiązane z MDBList ani przez nie wspierane. + IntroDB + Nuvio korzysta z API IntroDB do dostarczanych przez społeczność znaczników intro, podsumowań, napisów końcowych i podglądów używanych przez kontrolki pomijania. Nuvio nie jest powiązane z IntroDB ani przez nie wspierane. + MPVKit + Używany do odtwarzania w wersjach na iOS. + Kod źródłowy MPVKit jest licencjonowany na podstawie LGPL v3.0. Pakiety MPVKit, w tym biblioteki libmpv i FFmpeg, są również licencjonowane na podstawie LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Używany do odtwarzania w wersjach na Androida. + Licencjonowany na podstawie Apache License, wersja 2.0. Ładowanie list Trakt… Wybierz, gdzie zapisać ten tytuł w Trakt Wesprzyj @@ -416,6 +492,8 @@ Język aplikacji Wybierz język Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie. + Liquid Glass + Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Szybkie przełączanie profili z paska kart jest niedostępne, gdy ta opcja jest włączona. Dostosuj szerokość i zaokrąglenie rogów kart plakatów. WYŚWIETLANIE EKRAN GŁÓWNY @@ -442,9 +520,14 @@ %1$d z %2$d wybranych Pokaż Hero Wyświetl wyróżnioną karuzelę hero na górze ekranu głównego. Wybierz do 2 katalogów źródłowych poniżej. + Ukryj niewydane treści + Ukryj filmy i seriale, które nie zostały jeszcze wydane. + Ukryj podkreślenie katalogu + Usuń linię akcentu pod tytułami katalogów i kolekcji w całej aplikacji. %1$d z %2$d katalogów widocznych • %3$d źródeł hero wybranych Otwórz katalog tylko wtedy, gdy chcesz zmienić jego nazwę lub kolejność. Widoczne + Ukryj wartość Odtwarzacz, napisy i automatyczne odtwarzanie Zaokrąglenie karty STYL KARTY PLAKATU @@ -469,8 +552,19 @@ Gęsty Duży Standardowy + Pokaż wartość Pokaż okno kontynuowania od miejsca, w którym skończyłeś, po otwarciu aplikacji po wyjściu z odtwarzacza. Monit o wznowienie przy uruchomieniu + Rozmyj miniatury następnych odcinków w Kontynuuj oglądanie, aby uniknąć spoilerów. + Rozmyj nieobejrzane w Kontynuuj oglądanie + Uwzględnij nadchodzące odcinki w Kontynuuj oglądanie przed ich emisją. + Pokaż niewyemitowane następne odcinki + KOLEJNOŚĆ SORTOWANIA + Kolejność sortowania + Domyślna + Sortuj wszystkie elementy według czasu + Styl streamingowy + Wydane najpierw, nadchodzące na końcu STYL KARTY PRZY URUCHOMIENIU ZACHOWANIE NASTĘPNEGO @@ -483,6 +577,8 @@ Pozioma karta z informacjami Gdy włączone, Następny zawsze kontynuuje od najdalej obejrzanego odcinka. Gdy wyłączone, kontynuuje od ostatnio obejrzanego. Przydatne przy ponownym oglądaniu wcześniejszych odcinków. Następny od najdalszego odcinka + Preferuj miniatury odcinków, gdy są dostępne. + Preferuj miniatury odcinków w Kontynuuj oglądanie EKRAN GŁÓWNY ŹRÓDŁA Instaluj, usuwaj, odświeżaj i sortuj źródła treści. @@ -493,6 +589,34 @@ INTEGRACJE Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko. Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów. + Eksperymentalne źródła z kont chmurowych + Debrid + Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości. + Włącz źródła + Pokaż odtwarzalne wyniki z połączonych kont. + Najpierw dodaj klucz API. + Konto + Połącz swoje konto Torbox. + Klucz API Torbox + Wprowadź swój klucz API Torbox. + Wprowadź klucz API Torbox + Nie ustawiono + Natychmiastowe odtwarzanie + Przygotuj linki + Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania. + Źródła do przygotowania + Używaj niższej liczby, gdy to możliwe. Usługi Debrid mogą ograniczać liczbę linków rozwiązywanych w danym okresie. Otwarcie filmu lub odcinka może się wliczać do tych limitów, nawet jeśli nie naciśniesz Odtwórz, ponieważ linki są przygotowywane z wyprzedzeniem. + 1 źródło + %1$d źródeł + Formatowanie + Szablon nazwy + Kontroluje sposób wyświetlania nazw źródeł. + Szablon opisu + Kontroluje metadane wyświetlane pod każdym źródłem. + Resetuj formatowanie + Przywróć domyślne formatowanie źródeł. + Klucz API zweryfikowany. + Nie udało się zweryfikować tego klucza API. Dodaj klucz API MDBList poniżej przed włączeniem ocen. Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj. Klucz API @@ -522,6 +646,8 @@ Karty ze szczegółami na pierwszym planie Odcinki Sezony i lista odcinków dla seriali. + Rozmyj nieobejrzane odcinki + Rozmyj miniatury odcinków do momentu obejrzenia, aby uniknąć spoilerów. Grupa %1$d Podobne Wiersz rekomendacji. @@ -588,6 +714,10 @@ Anime Skip ID klienta AnimeSkip Wprowadź ID klienta API AnimeSkip. Pobierz je na anime-skip.com. + Włącz przesyłanie intro + Pokaż przycisk do przesyłania znaczników intro/outro do bazy społeczności. + Klucz API IntroDB + Wprowadź klucz API IntroDB, aby przesyłać znaczniki czasowe. Wymagany do przesyłania. Wyszukuj również w AnimeSkip znaczniki pomijania (wymaga ID klienta). Automatyczne odtwarzanie następnego odcinka Automatycznie znajdź i odtwórz następny odcinek po osiągnięciu progu. @@ -603,6 +733,11 @@ %1$d godzin Włącz libass Użyj libass do renderowania napisów ASS/SSA zamiast domyślnego renderera. + Zewnętrzny odtwarzacz + Aplikacja zewnętrznego odtwarzacza + Otwórz nowe odtwarzanie w domyślnej aplikacji wideo Androida lub selektorze systemowym. + Otwórz nowe odtwarzanie w wybranym zainstalowanym odtwarzaczu. + Brak zainstalowanych obsługiwanych zewnętrznych odtwarzaczy Prędkość przy przytrzymaniu Przytrzymaj, aby przyspieszyć Przytrzymaj dowolne miejsce na powierzchni odtwarzacza, aby tymczasowo zwiększyć prędkość odtwarzania. @@ -621,6 +756,8 @@ Brak Preferuj grupę binge Przy automatycznym odtwarzaniu preferuj strumień z tej samej grupy binge co bieżący. + Ponownie użyj grupy binge + Zapamiętaj i ponownie użyj ostatniej grupy binge między sesjami (Kontynuuj oglądanie, Szczegóły itp.). Preferowany język audio Preferowany język napisów Szablony @@ -744,6 +881,28 @@ Otwórz logowanie Trakt Twoje akcje zapisywania mogą teraz celować w listę obserwowanych i osobiste listy Trakt. Zaloguj się w Trakt, aby włączyć zapisywanie na listach i tryb biblioteki Trakt. + Źródło biblioteki + Wybierz, której biblioteki używać do zapisywania i przeglądania kolekcji + Źródło biblioteki + Wybierz, gdzie zapisywać i zarządzać elementami biblioteki + Trakt + Biblioteka Nuvio + Wybrano bibliotekę Trakt + Wybrano bibliotekę Nuvio + Postęp oglądania + Wybierz, które źródło postępu obsługuje wznawianie i Kontynuuj oglądanie + Postęp oglądania + Wybierz, czy wznawianie i Kontynuuj oglądanie powinno korzystać z Trakt czy Nuvio Sync, podczas gdy scrobblowanie Trakt pozostaje aktywne. + Trakt + Nuvio Sync + Źródło postępu ustawione na Trakt + Źródło postępu ustawione na Nuvio Sync + Okno Kontynuuj oglądanie + Historia Trakt uwzględniana w Kontynuuj oglądanie + Okno Kontynuuj oglądanie + Wybierz, ile aktywności Trakt ma się pojawiać w Kontynuuj oglądanie. + Cała historia + %1$d dni Ocena widzów IMDb Letterboxd @@ -934,9 +1093,14 @@ Zablokowane. Spróbuj ponownie za %1$ds Opcje awatarów pojawią się tutaj po załadowaniu katalogu. Awatar: %1$s + Wprowadź prawidłowy URL obrazu http:// lub https://. Wybierz awatar Wybierz awatar poniżej. Utwórz profil + Wybrano niestandardowy URL awatara. + Niestandardowy URL awatara + Wklej link do obrazu lub zostaw puste, aby użyć wbudowanego katalogu awatarów. + https://example.com/avatar.png Wszystkie dane profilu „%1$s" zostaną trwale usunięte. Usuń profil Dodaj profil @@ -968,6 +1132,8 @@ Sprawdzanie kolejnych dodatków… Kopiuj link strumienia Pobierz plik + Otwórz w zewnętrznym odtwarzaczu + Otwórz w wewnętrznym odtwarzaczu Zainstalowane dodatki strumieni nie zwróciły prawidłowej odpowiedzi. Nie można załadować strumieni Najpierw zainstaluj dodatek, aby załadować strumienie dla tego tytułu. @@ -987,6 +1153,13 @@ Wznów od %1$d% Wznów od %1$s ROZMIAR %1$s + Ten typ strumienia nie jest obsługiwany + Dodaj klucz API Debrid w Ustawieniach. + Ten wynik Debrid wygasł. Odświeżanie strumieni. + Nie udało się rozwiązać tego strumienia Debrid. + Nie udało się otworzyć zewnętrznego odtwarzacza + Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach + Brak dostępnego zewnętrznego odtwarzacza Zamknij zwiastun Nie można odtworzyć zwiastuna Nie udało się załadować list Trakt @@ -1032,6 +1205,7 @@ Pobieranie nie powiodło się Wstrzymano %1$s Usuń + Usunąć %1$s z %2$s? Usunąć %1$s z biblioteki? Usunąć z biblioteki? Film @@ -1075,6 +1249,7 @@ Folder %1$d w „%2$s" ma puste id. Folder „%1$s" w „%2$s" ma pusty tytuł. Źródło %1$d w folderze „%2$s" ma puste pola. + Źródło %1$d w folderze '%2$s' nie ma ID listy Trakt. Nieprawidłowy JSON: %1$s Nie znaleziono dodatku: %1$s Styczeń @@ -1148,6 +1323,14 @@ Nowy odcinek jest już dostępny %1$s jest już dostępny Premiery odcinków + Alkohol/Narkotyki + Przerażające + Nagość + Wulgaryzmy + Łagodne + Umiarkowane + Intensywne + Przemoc Twórca Reżyser Scenarzysta diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 955e47cd..601a432c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -18,6 +18,7 @@ Resume Retry Save + Saving… Validate Installing Addons @@ -596,10 +597,15 @@ Add an API key first. Account Connect your Torbox account. + Torbox API Key + Enter your Torbox API key. + Enter Torbox API key + Not set Instant Playback Prepare links Resolve the first sources before playback starts. Sources to prepare + Use a lower count when possible. Debrid services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. 1 source %1$d sources Formatting @@ -607,6 +613,8 @@ Controls how source names appear. Description template Controls the metadata shown under each source. + Reset formatting + Restore default source formatting. API key validated. Could not validate this API key. Add your MDBList API key below before turning ratings on. @@ -748,6 +756,8 @@ None Prefer Binge Group (Next Episode) Try the same source profile first (same addon/quality group) before normal auto-play rules. + Reuse Binge Group + Remember and reuse the last binge group across sessions (Continue Watching, Details, etc.). Preferred Audio Language Preferred Language Presets diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4058c118..e486476d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1527,6 +1527,7 @@ private fun MainAppContent( StreamsRepository.reload( type = launch.type, videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, season = launch.seasonNumber, episode = launch.episodeNumber, manualSelection = launch.manualSelection, @@ -1636,6 +1637,7 @@ private fun MainAppContent( StreamsRepository.reload( type = launch.type, videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, season = launch.seasonNumber, episode = launch.episodeNumber, manualSelection = launch.manualSelection, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt index e226f637..86520e0a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -32,6 +33,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay @@ -151,6 +153,20 @@ fun NuvioAnimatedWatchedBadge( } } +@Composable +fun BoxScope.NuvioPosterWatchedOverlay( + isWatched: Boolean, + modifier: Modifier = Modifier, + padding: Dp = 6.dp, +) { + NuvioAnimatedWatchedBadge( + isVisible = isWatched, + modifier = modifier + .align(Alignment.TopEnd) + .padding(padding), + ) +} + @Composable private fun PosterSheetHeader( item: MetaPreview, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index ace10d77..b0cf02fd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -179,12 +179,7 @@ fun NuvioPosterCard( } } - NuvioAnimatedWatchedBadge( - isVisible = isWatched, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(6.dp), - ) + NuvioPosterWatchedOverlay(isWatched = isWatched) } if (shouldShowTitleBelow) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index c611b161..e068aa4e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -47,6 +47,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding @@ -55,6 +56,8 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.stableKey +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -79,6 +82,10 @@ fun CatalogScreen( val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val posterCardStyle = rememberPosterCardStyleUiState() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() var headerHeightPx by remember { mutableIntStateOf(0) } var observedOfflineState by remember { mutableStateOf(false) } @@ -187,6 +194,10 @@ fun CatalogScreen( item = item, cornerRadiusDp = posterCardStyle.cornerRadiusDp, hideLabels = posterCardStyle.hideLabelsEnabled, + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedUiState.watchedKeys, + item = item, + ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, ) @@ -258,6 +269,7 @@ private fun CatalogPosterTile( item: MetaPreview, cornerRadiusDp: Int, hideLabels: Boolean, + isWatched: Boolean, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, ) { @@ -280,6 +292,7 @@ private fun CatalogPosterTile( contentScale = ContentScale.Crop, ) } + NuvioPosterWatchedOverlay(isWatched = isWatched) } if (!hideLabels) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index 6101d18a..5881ce2e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -61,6 +61,8 @@ import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.canOpenCatalog import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.components.HomeCatalogRowSection +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -79,6 +81,10 @@ fun FolderDetailScreen( onPosterClick: (MetaPreview) -> Unit, ) { val uiState by FolderDetailRepository.uiState.collectAsState() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsState() val folder = uiState.folder val coverImageUrl = folder?.coverImageUrl?.takeIf { it.isNotBlank() } val density = LocalDensity.current @@ -160,18 +166,21 @@ fun FolderDetailScreen( when (uiState.viewMode) { FolderViewMode.TABBED_GRID -> TabbedGridContent( uiState = uiState, + watchedKeys = watchedUiState.watchedKeys, modifier = Modifier.weight(1f).then(contentModifier), onTabSelected = { FolderDetailRepository.selectTab(it) }, onPosterClick = onPosterClick, ) FolderViewMode.ROWS -> RowsContent( uiState = uiState, + watchedKeys = watchedUiState.watchedKeys, modifier = Modifier.weight(1f).then(contentModifier), onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, ) FolderViewMode.FOLLOW_LAYOUT -> RowsContent( uiState = uiState, + watchedKeys = watchedUiState.watchedKeys, modifier = Modifier.weight(1f).then(contentModifier), onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, @@ -199,6 +208,7 @@ private fun FolderCoverImage( @Composable private fun TabbedGridContent( uiState: FolderDetailUiState, + watchedKeys: Set, modifier: Modifier = Modifier, onTabSelected: (Int) -> Unit, onPosterClick: (MetaPreview) -> Unit, @@ -285,6 +295,10 @@ private fun TabbedGridContent( imageUrl = item.poster, shape = NuvioPosterShape.Poster, detailLine = item.releaseInfo, + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = item, + ), onClick = { onPosterClick(item) }, ) } @@ -304,6 +318,7 @@ private fun TabbedGridContent( @Composable private fun RowsContent( uiState: FolderDetailUiState, + watchedKeys: Set, modifier: Modifier = Modifier, onCatalogClick: (HomeCatalogSection) -> Unit, onPosterClick: (MetaPreview) -> Unit, @@ -340,6 +355,7 @@ private fun RowsContent( } else { null }, + watchedKeys = watchedKeys, onPosterClick = { onPosterClick(it) }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt index b2d40b0f..6e48cc07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -1,10 +1,19 @@ package com.nuvio.app.features.debrid +import kotlinx.serialization.Serializable + data class DebridSettings( val enabled: Boolean = false, val torboxApiKey: String = "", val realDebridApiKey: String = "", val instantPlaybackPreparationLimit: Int = 0, + val streamMaxResults: Int = 0, + val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT, + val streamMinimumQuality: DebridStreamMinimumQuality = DebridStreamMinimumQuality.ANY, + val streamDolbyVisionFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY, + val streamHdrFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY, + val streamCodecFilter: DebridStreamCodecFilter = DebridStreamCodecFilter.ANY, + val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(), val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, ) { @@ -12,8 +21,236 @@ data class DebridSettings( get() = DebridProviders.configuredServices(this).isNotEmpty() } -internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 -internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 +const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 +const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 -internal fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = +enum class DebridStreamSortMode { + DEFAULT, + QUALITY_DESC, + SIZE_DESC, + SIZE_ASC, +} + +enum class DebridStreamMinimumQuality(val minResolution: Int) { + ANY(0), + P720(720), + P1080(1080), + P2160(2160), +} + +enum class DebridStreamFeatureFilter { + ANY, + EXCLUDE, + ONLY, +} + +enum class DebridStreamCodecFilter { + ANY, + H264, + HEVC, + AV1, +} + +@Serializable +data class DebridStreamPreferences( + val maxResults: Int = 0, + val maxPerResolution: Int = 0, + val maxPerQuality: Int = 0, + val sizeMinGb: Int = 0, + val sizeMaxGb: Int = 0, + val preferredResolutions: List = DebridStreamResolution.defaultOrder, + val requiredResolutions: List = emptyList(), + val excludedResolutions: List = emptyList(), + val preferredQualities: List = DebridStreamQuality.defaultOrder, + val requiredQualities: List = emptyList(), + val excludedQualities: List = emptyList(), + val preferredVisualTags: List = DebridStreamVisualTag.defaultOrder, + val requiredVisualTags: List = emptyList(), + val excludedVisualTags: List = emptyList(), + val preferredAudioTags: List = DebridStreamAudioTag.defaultOrder, + val requiredAudioTags: List = emptyList(), + val excludedAudioTags: List = emptyList(), + val preferredAudioChannels: List = DebridStreamAudioChannel.defaultOrder, + val requiredAudioChannels: List = emptyList(), + val excludedAudioChannels: List = emptyList(), + val preferredEncodes: List = DebridStreamEncode.defaultOrder, + val requiredEncodes: List = emptyList(), + val excludedEncodes: List = emptyList(), + val preferredLanguages: List = emptyList(), + val requiredLanguages: List = emptyList(), + val excludedLanguages: List = emptyList(), + val requiredReleaseGroups: List = emptyList(), + val excludedReleaseGroups: List = emptyList(), + val sortCriteria: List = DebridStreamSortCriterion.defaultOrder, +) + +@Serializable +enum class DebridStreamResolution(val label: String, val value: Int) { + P2160("2160p", 2160), + P1440("1440p", 1440), + P1080("1080p", 1080), + P720("720p", 720), + P576("576p", 576), + P480("480p", 480), + P360("360p", 360), + UNKNOWN("Unknown", 0); + + companion object { + val defaultOrder = listOf(P2160, P1440, P1080, P720, P576, P480, P360, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamQuality(val label: String) { + BLURAY_REMUX("BluRay REMUX"), + BLURAY("BluRay"), + WEB_DL("WEB-DL"), + WEBRIP("WEBRip"), + HDRIP("HDRip"), + HD_RIP("HC HD-Rip"), + DVDRIP("DVDRip"), + HDTV("HDTV"), + CAM("CAM"), + TS("TS"), + TC("TC"), + SCR("SCR"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(BLURAY_REMUX, BLURAY, WEB_DL, WEBRIP, HDRIP, HD_RIP, DVDRIP, HDTV, CAM, TS, TC, SCR, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamVisualTag(val label: String) { + HDR_DV("HDR+DV"), + DV_ONLY("DV Only"), + HDR_ONLY("HDR Only"), + HDR10_PLUS("HDR10+"), + HDR10("HDR10"), + DV("DV"), + HDR("HDR"), + HLG("HLG"), + TEN_BIT("10bit"), + THREE_D("3D"), + IMAX("IMAX"), + AI("AI"), + SDR("SDR"), + H_OU("H-OU"), + H_SBS("H-SBS"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(HDR_DV, DV_ONLY, HDR_ONLY, HDR10_PLUS, HDR10, DV, HDR, HLG, TEN_BIT, IMAX, SDR, THREE_D, AI, H_OU, H_SBS, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioTag(val label: String) { + ATMOS("Atmos"), + DD_PLUS("DD+"), + DD("DD"), + DTS_X("DTS:X"), + DTS_HD_MA("DTS-HD MA"), + DTS_HD("DTS-HD"), + DTS_ES("DTS-ES"), + DTS("DTS"), + TRUEHD("TrueHD"), + OPUS("OPUS"), + FLAC("FLAC"), + AAC("AAC"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(ATMOS, DD_PLUS, DD, DTS_X, DTS_HD_MA, DTS_HD, DTS_ES, DTS, TRUEHD, OPUS, FLAC, AAC, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioChannel(val label: String) { + CH_2_0("2.0"), + CH_5_1("5.1"), + CH_6_1("6.1"), + CH_7_1("7.1"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(CH_7_1, CH_6_1, CH_5_1, CH_2_0, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamEncode(val label: String) { + AV1("AV1"), + HEVC("HEVC"), + AVC("AVC"), + XVID("XviD"), + DIVX("DivX"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(AV1, HEVC, AVC, XVID, DIVX, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamLanguage(val code: String, val label: String) { + EN("en", "English"), + HI("hi", "Hindi"), + IT("it", "Italian"), + ES("es", "Spanish"), + FR("fr", "French"), + DE("de", "German"), + PT("pt", "Portuguese"), + PL("pl", "Polish"), + CS("cs", "Czech"), + LA("la", "Latino"), + JA("ja", "Japanese"), + KO("ko", "Korean"), + ZH("zh", "Chinese"), + MULTI("multi", "Multi"), + UNKNOWN("unknown", "Unknown"), +} + +@Serializable +data class DebridStreamSortCriterion( + val key: DebridStreamSortKey = DebridStreamSortKey.RESOLUTION, + val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC, +) { + companion object { + val defaultOrder = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.VISUAL_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.ENCODE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } +} + +@Serializable +enum class DebridStreamSortKey(val label: String) { + RESOLUTION("Resolution"), + QUALITY("Quality"), + VISUAL_TAG("Visual tag"), + AUDIO_TAG("Audio"), + AUDIO_CHANNEL("Audio channel"), + ENCODE("Encode"), + SIZE("Size"), + LANGUAGE("Language"), + RELEASE_GROUP("Release group"), +} + +@Serializable +enum class DebridStreamSortDirection { + ASC, + DESC, +} + +fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT) + +fun normalizeDebridStreamMaxResults(value: Int): Int = + if (value <= 0) 0 else value.coerceIn(1, 100) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt index 17938a41..d8c7625b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -3,16 +3,34 @@ package com.nuvio.app.features.debrid import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json object DebridSettingsRepository { private val _uiState = MutableStateFlow(DebridSettings()) val uiState: StateFlow = _uiState.asStateFlow() + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + private var hasLoaded = false private var enabled = false private var torboxApiKey = "" private var realDebridApiKey = "" private var instantPlaybackPreparationLimit = 0 + private var streamMaxResults = 0 + private var streamSortMode = DebridStreamSortMode.DEFAULT + private var streamMinimumQuality = DebridStreamMinimumQuality.ANY + private var streamDolbyVisionFilter = DebridStreamFeatureFilter.ANY + private var streamHdrFilter = DebridStreamFeatureFilter.ANY + private var streamCodecFilter = DebridStreamCodecFilter.ANY + private var streamPreferences = DebridStreamPreferences() private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE @@ -68,6 +86,78 @@ object DebridSettingsRepository { DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized) } + fun setStreamMaxResults(value: Int) { + ensureLoaded() + val normalized = normalizeDebridStreamMaxResults(value) + if (streamMaxResults == normalized && streamPreferences.maxResults == normalized) return + streamMaxResults = normalized + streamPreferences = streamPreferences.copy(maxResults = normalized).normalized() + publish() + DebridSettingsStorage.saveStreamMaxResults(normalized) + saveStreamPreferences() + } + + fun setStreamSortMode(value: DebridStreamSortMode) { + ensureLoaded() + if (streamSortMode == value && streamPreferences.sortCriteria == sortCriteriaForLegacyMode(value)) return + streamSortMode = value + streamPreferences = streamPreferences.copy(sortCriteria = sortCriteriaForLegacyMode(value)).normalized() + publish() + DebridSettingsStorage.saveStreamSortMode(value.name) + saveStreamPreferences() + } + + fun setStreamMinimumQuality(value: DebridStreamMinimumQuality) { + ensureLoaded() + if (streamMinimumQuality == value && streamPreferences.requiredResolutions == resolutionsForMinimumQuality(value)) return + streamMinimumQuality = value + streamPreferences = streamPreferences.copy(requiredResolutions = resolutionsForMinimumQuality(value)).normalized() + publish() + DebridSettingsStorage.saveStreamMinimumQuality(value.name) + saveStreamPreferences() + } + + fun setStreamDolbyVisionFilter(value: DebridStreamFeatureFilter) { + ensureLoaded() + if (streamDolbyVisionFilter == value) return + streamDolbyVisionFilter = value + streamPreferences = streamPreferences.applyDolbyVisionFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamDolbyVisionFilter(value.name) + saveStreamPreferences() + } + + fun setStreamHdrFilter(value: DebridStreamFeatureFilter) { + ensureLoaded() + if (streamHdrFilter == value) return + streamHdrFilter = value + streamPreferences = streamPreferences.applyHdrFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamHdrFilter(value.name) + saveStreamPreferences() + } + + fun setStreamCodecFilter(value: DebridStreamCodecFilter) { + ensureLoaded() + if (streamCodecFilter == value) return + streamCodecFilter = value + streamPreferences = streamPreferences.applyCodecFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamCodecFilter(value.name) + saveStreamPreferences() + } + + fun setStreamPreferences(value: DebridStreamPreferences) { + ensureLoaded() + val normalized = value.normalized() + if (streamPreferences == normalized) return + streamPreferences = normalized + streamMaxResults = normalized.maxResults + publish() + DebridSettingsStorage.saveStreamMaxResults(streamMaxResults) + saveStreamPreferences() + } + fun setStreamNameTemplate(value: String) { ensureLoaded() val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } @@ -86,15 +176,22 @@ object DebridSettingsRepository { DebridSettingsStorage.saveStreamDescriptionTemplate(normalized) } - fun resetStreamTemplates() { + fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) { ensureLoaded() - streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE - streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } publish() DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate) DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate) } + fun resetStreamTemplates() { + setStreamTemplates( + nameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE, + descriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + ) + } + private fun disableIfNoKeys() { if (!hasVisibleApiKey()) { enabled = false @@ -114,6 +211,36 @@ object DebridSettingsRepository { instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit( DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0, ) + streamMaxResults = normalizeDebridStreamMaxResults(DebridSettingsStorage.loadStreamMaxResults() ?: 0) + streamSortMode = enumValueOrDefault( + DebridSettingsStorage.loadStreamSortMode(), + DebridStreamSortMode.DEFAULT, + ) + streamMinimumQuality = enumValueOrDefault( + DebridSettingsStorage.loadStreamMinimumQuality(), + DebridStreamMinimumQuality.ANY, + ) + streamDolbyVisionFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamDolbyVisionFilter(), + DebridStreamFeatureFilter.ANY, + ) + streamHdrFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamHdrFilter(), + DebridStreamFeatureFilter.ANY, + ) + streamCodecFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamCodecFilter(), + DebridStreamCodecFilter.ANY, + ) + streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences()) + ?: legacyStreamPreferences( + maxResults = streamMaxResults, + sortMode = streamSortMode, + minimumQuality = streamMinimumQuality, + dolbyVisionFilter = streamDolbyVisionFilter, + hdrFilter = streamHdrFilter, + codecFilter = streamCodecFilter, + ) streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate() ?.takeIf { it.isNotBlank() } ?: DebridStreamFormatterDefaults.NAME_TEMPLATE @@ -129,8 +256,164 @@ object DebridSettingsRepository { torboxApiKey = torboxApiKey, realDebridApiKey = realDebridApiKey, instantPlaybackPreparationLimit = instantPlaybackPreparationLimit, + streamMaxResults = streamMaxResults, + streamSortMode = streamSortMode, + streamMinimumQuality = streamMinimumQuality, + streamDolbyVisionFilter = streamDolbyVisionFilter, + streamHdrFilter = streamHdrFilter, + streamCodecFilter = streamCodecFilter, + streamPreferences = streamPreferences, streamNameTemplate = streamNameTemplate, streamDescriptionTemplate = streamDescriptionTemplate, ) } + + private fun saveStreamPreferences() { + DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized())) + } + + private inline fun > enumValueOrDefault(value: String?, default: T): T = + runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default) + + private fun parseStreamPreferences(value: String?): DebridStreamPreferences? { + if (value.isNullOrBlank()) return null + return try { + json.decodeFromString(value).normalized() + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + } } + +internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = + copy( + maxResults = normalizeDebridStreamMaxResults(maxResults), + maxPerResolution = maxPerResolution.coerceIn(0, 100), + maxPerQuality = maxPerQuality.coerceIn(0, 100), + sizeMinGb = sizeMinGb.coerceIn(0, 100), + sizeMaxGb = sizeMaxGb.coerceIn(0, 100), + preferredResolutions = preferredResolutions.ifEmpty { DebridStreamResolution.defaultOrder }, + requiredResolutions = requiredResolutions, + excludedResolutions = excludedResolutions, + preferredQualities = preferredQualities.ifEmpty { DebridStreamQuality.defaultOrder }, + requiredQualities = requiredQualities, + excludedQualities = excludedQualities, + preferredVisualTags = preferredVisualTags.ifEmpty { DebridStreamVisualTag.defaultOrder }, + requiredVisualTags = requiredVisualTags, + excludedVisualTags = excludedVisualTags, + preferredAudioTags = preferredAudioTags.ifEmpty { DebridStreamAudioTag.defaultOrder }, + requiredAudioTags = requiredAudioTags, + excludedAudioTags = excludedAudioTags, + preferredAudioChannels = preferredAudioChannels.ifEmpty { DebridStreamAudioChannel.defaultOrder }, + requiredAudioChannels = requiredAudioChannels, + excludedAudioChannels = excludedAudioChannels, + preferredEncodes = preferredEncodes.ifEmpty { DebridStreamEncode.defaultOrder }, + requiredEncodes = requiredEncodes, + excludedEncodes = excludedEncodes, + preferredLanguages = preferredLanguages, + requiredLanguages = requiredLanguages, + excludedLanguages = excludedLanguages, + requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }, + ) + +private fun legacyStreamPreferences( + maxResults: Int, + sortMode: DebridStreamSortMode, + minimumQuality: DebridStreamMinimumQuality, + dolbyVisionFilter: DebridStreamFeatureFilter, + hdrFilter: DebridStreamFeatureFilter, + codecFilter: DebridStreamCodecFilter, +): DebridStreamPreferences = + DebridStreamPreferences( + maxResults = normalizeDebridStreamMaxResults(maxResults), + sortCriteria = sortCriteriaForLegacyMode(sortMode), + requiredResolutions = resolutionsForMinimumQuality(minimumQuality), + ) + .applyDolbyVisionFilter(dolbyVisionFilter) + .applyHdrFilter(hdrFilter) + .applyCodecFilter(codecFilter) + .normalized() + +private fun DebridStreamPreferences.applyDolbyVisionFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = (excludedVisualTags + dolbyVisionTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + dolbyVisionTags).distinct(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyHdrFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = (excludedVisualTags + hdrTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + hdrTags).distinct(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyCodecFilter( + filter: DebridStreamCodecFilter, +): DebridStreamPreferences = + copy( + requiredEncodes = when (filter) { + DebridStreamCodecFilter.ANY -> emptyList() + DebridStreamCodecFilter.H264 -> listOf(DebridStreamEncode.AVC) + DebridStreamCodecFilter.HEVC -> listOf(DebridStreamEncode.HEVC) + DebridStreamCodecFilter.AV1 -> listOf(DebridStreamEncode.AV1) + }, + ) + +private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): List = + DebridStreamResolution.defaultOrder.filter { + it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN + } + +private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List = + when (mode) { + DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + } + +private val dolbyVisionTags = listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, +) + +private val hdrTags = listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 6c4f238f..62fddac4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -11,6 +11,20 @@ internal expect object DebridSettingsStorage { fun saveRealDebridApiKey(apiKey: String) fun loadInstantPlaybackPreparationLimit(): Int? fun saveInstantPlaybackPreparationLimit(limit: Int) + fun loadStreamMaxResults(): Int? + fun saveStreamMaxResults(maxResults: Int) + fun loadStreamSortMode(): String? + fun saveStreamSortMode(mode: String) + fun loadStreamMinimumQuality(): String? + fun saveStreamMinimumQuality(quality: String) + fun loadStreamDolbyVisionFilter(): String? + fun saveStreamDolbyVisionFilter(filter: String) + fun loadStreamHdrFilter(): String? + fun saveStreamHdrFilter(filter: String) + fun loadStreamCodecFilter(): String? + fun saveStreamCodecFilter(filter: String) + fun loadStreamPreferences(): String? + fun saveStreamPreferences(preferences: String) fun loadStreamNameTemplate(): String? fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt index 28c03dde..6647d607 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt @@ -5,8 +5,8 @@ import com.nuvio.app.features.streams.StreamItem object DirectDebridStreamFilter { const val FALLBACK_SOURCE_NAME = "Direct Debrid" - fun filterInstant(streams: List): List = - streams + fun filterInstant(streams: List, settings: DebridSettings? = null): List { + val instantStreams = streams .filter(::isInstantCandidate) .map { stream -> val providerId = stream.clientResolve?.service @@ -27,6 +27,8 @@ object DirectDebridStreamFilter { stream.title, ).joinToString("|") } + return if (settings == null) instantStreams else applyPreferences(instantStreams, settings) + } fun isInstantCandidate(stream: StreamItem): Boolean { val resolve = stream.clientResolve ?: return false @@ -37,5 +39,387 @@ object DirectDebridStreamFilter { fun isDirectDebridSourceName(addonName: String): Boolean = DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) } -} + private fun applyPreferences(streams: List, settings: DebridSettings): List { + val preferences = effectivePreferences(settings) + return streams.map { it to streamFacts(it, preferences) } + .filter { (_, facts) -> facts.matchesFilters(preferences) } + .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) } + .let { sorted -> applyLimits(sorted, preferences) } + .map { it.first } + } + + private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences { + val default = DebridStreamPreferences() + if (settings.streamPreferences != default) return settings.streamPreferences.normalized() + if ( + settings.streamMaxResults == 0 && + settings.streamSortMode == DebridStreamSortMode.DEFAULT && + settings.streamMinimumQuality == DebridStreamMinimumQuality.ANY && + settings.streamDolbyVisionFilter == DebridStreamFeatureFilter.ANY && + settings.streamHdrFilter == DebridStreamFeatureFilter.ANY && + settings.streamCodecFilter == DebridStreamCodecFilter.ANY + ) { + return default + } + var preferences = default.copy( + maxResults = settings.streamMaxResults, + sortCriteria = when (settings.streamSortMode) { + DebridStreamSortMode.DEFAULT -> default.sortCriteria + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + }, + requiredResolutions = DebridStreamResolution.defaultOrder.filter { + it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN + }, + ) + preferences = when (settings.streamDolbyVisionFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + preferences = when (settings.streamHdrFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + return when (settings.streamCodecFilter) { + DebridStreamCodecFilter.ANY -> preferences + DebridStreamCodecFilter.H264 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AVC)) + DebridStreamCodecFilter.HEVC -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.HEVC)) + DebridStreamCodecFilter.AV1 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AV1)) + }.normalized() + } + + private fun applyLimits( + streams: List>, + preferences: DebridStreamPreferences, + ): List> { + val resolutionCounts = mutableMapOf() + val qualityCounts = mutableMapOf() + val result = mutableListOf>() + for (stream in streams) { + if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break + if (preferences.maxPerResolution > 0) { + val count = resolutionCounts[stream.second.resolution] ?: 0 + if (count >= preferences.maxPerResolution) continue + } + if (preferences.maxPerQuality > 0) { + val count = qualityCounts[stream.second.quality] ?: 0 + if (count >= preferences.maxPerQuality) continue + } + resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1 + qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1 + result += stream + } + return result + } + + private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean { + if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false + if (resolution in preferences.excludedResolutions) return false + if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false + if (quality in preferences.excludedQualities) return false + if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false + if (visualTags.any { it in preferences.excludedVisualTags }) return false + if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false + if (audioTags.any { it in preferences.excludedAudioTags }) return false + if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false + if (audioChannels.any { it in preferences.excludedAudioChannels }) return false + if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false + if (encode in preferences.excludedEncodes) return false + if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false + if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false + if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false + if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false + return true + } + + private fun compareFacts( + left: StreamFacts, + right: StreamFacts, + criteria: List, + ): Int { + for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { + val comparison = compareKey(left, right, criterion) + if (comparison != 0) return comparison + } + return 0 + } + + private fun compareKey( + left: StreamFacts, + right: StreamFacts, + criterion: DebridStreamSortCriterion, + ): Int { + val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1 + return when (criterion.key) { + DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction + DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction + DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction + DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction + DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction + DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction + DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction + DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction + DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true) + } + } + + private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts { + val parsed = stream.clientResolve?.stream?.raw?.parsed + val searchText = streamSearchText(stream) + val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText) + val quality = streamQuality(parsed?.quality, searchText) + val visualTags = streamVisualTags(parsed?.hdr.orEmpty(), searchText) + val audioTags = streamAudioTags(parsed?.audio.orEmpty(), searchText) + val audioChannels = streamAudioChannels(parsed?.channels.orEmpty(), searchText) + val encode = streamEncode(parsed?.codec, searchText) + val languages = parsed?.languages.orEmpty().mapNotNull { languageFor(it) }.ifEmpty { + DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) } + } + val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText) + return StreamFacts( + resolution = resolution, + quality = quality, + visualTags = visualTags, + audioTags = audioTags, + audioChannels = audioChannels, + encode = encode, + languages = languages, + releaseGroup = releaseGroup, + size = streamSize(stream), + resolutionRank = rank(resolution, preferences.preferredResolutions), + qualityRank = rank(quality, preferences.preferredQualities), + visualRank = rankAny(visualTags, preferences.preferredVisualTags), + audioRank = rankAny(audioTags, preferences.preferredAudioTags), + channelRank = rankAny(audioChannels, preferences.preferredAudioChannels), + encodeRank = rank(encode, preferences.preferredEncodes), + languageRank = if (languages.isEmpty()) Int.MAX_VALUE else languages.minOf { rank(it, preferences.preferredLanguages) }, + ) + } + + private fun streamResolution(vararg values: String?): DebridStreamResolution = + values.firstNotNullOfOrNull { resolutionValue(it) } ?: DebridStreamResolution.UNKNOWN + + private fun resolutionValue(value: String?): DebridStreamResolution? { + val normalized = value?.lowercase().orEmpty() + return when { + normalized.hasResolutionToken("2160p?", "4k", "uhd") -> DebridStreamResolution.P2160 + normalized.hasResolutionToken("1440p?", "2k") -> DebridStreamResolution.P1440 + normalized.hasResolutionToken("1080p?", "fhd") -> DebridStreamResolution.P1080 + normalized.hasResolutionToken("720p?", "hd") -> DebridStreamResolution.P720 + normalized.hasResolutionToken("576p?") -> DebridStreamResolution.P576 + normalized.hasResolutionToken("480p?", "sd") -> DebridStreamResolution.P480 + normalized.hasResolutionToken("360p?") -> DebridStreamResolution.P360 + else -> null + } + } + + private fun streamQuality(parsedQuality: String?, searchText: String): DebridStreamQuality { + val text = listOfNotNull(parsedQuality, searchText).joinToString(" ").lowercase() + return when { + text.contains("remux") -> DebridStreamQuality.BLURAY_REMUX + text.contains("blu-ray") || text.contains("bluray") || text.contains("bdrip") || text.contains("brrip") -> DebridStreamQuality.BLURAY + text.contains("web-dl") || text.contains("webdl") -> DebridStreamQuality.WEB_DL + text.contains("webrip") || text.contains("web-rip") -> DebridStreamQuality.WEBRIP + text.contains("hdrip") -> DebridStreamQuality.HDRIP + text.contains("hd-rip") || text.contains("hcrip") -> DebridStreamQuality.HD_RIP + text.contains("dvdrip") -> DebridStreamQuality.DVDRIP + text.contains("hdtv") -> DebridStreamQuality.HDTV + text.hasToken("cam") -> DebridStreamQuality.CAM + text.hasToken("ts") -> DebridStreamQuality.TS + text.hasToken("tc") -> DebridStreamQuality.TC + text.hasToken("scr") -> DebridStreamQuality.SCR + else -> DebridStreamQuality.UNKNOWN + } + } + + private fun streamVisualTags(parsedHdr: List, searchText: String): List { + val text = (parsedHdr + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + val hasDv = parsedHdr.any { it.isDolbyVisionToken() } || + Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|$)").containsMatchIn(searchText) + val hasHdr = parsedHdr.any { it.isHdrToken() } || + Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|$)").containsMatchIn(searchText) + if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV + if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY + if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY + if (text.contains("hdr10+") || text.contains("hdr10plus")) tags += DebridStreamVisualTag.HDR10_PLUS + if (text.contains("hdr10")) tags += DebridStreamVisualTag.HDR10 + if (hasDv) tags += DebridStreamVisualTag.DV + if (hasHdr) tags += DebridStreamVisualTag.HDR + if (text.hasToken("hlg")) tags += DebridStreamVisualTag.HLG + if (text.contains("10bit") || text.contains("10 bit")) tags += DebridStreamVisualTag.TEN_BIT + if (text.hasToken("3d")) tags += DebridStreamVisualTag.THREE_D + if (text.hasToken("imax")) tags += DebridStreamVisualTag.IMAX + if (text.hasToken("ai")) tags += DebridStreamVisualTag.AI + if (text.hasToken("sdr")) tags += DebridStreamVisualTag.SDR + if (text.contains("h-ou")) tags += DebridStreamVisualTag.H_OU + if (text.contains("h-sbs")) tags += DebridStreamVisualTag.H_SBS + return tags.distinct().ifEmpty { listOf(DebridStreamVisualTag.UNKNOWN) } + } + + private fun streamAudioTags(parsedAudio: List, searchText: String): List { + val text = (parsedAudio + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + if (text.hasToken("atmos")) tags += DebridStreamAudioTag.ATMOS + if (text.contains("dd+") || text.contains("ddp") || text.contains("dolby digital plus")) tags += DebridStreamAudioTag.DD_PLUS + if (text.hasToken("dd") || text.contains("ac3") || text.contains("dolby digital")) tags += DebridStreamAudioTag.DD + if (text.contains("dts:x") || text.contains("dtsx")) tags += DebridStreamAudioTag.DTS_X + if (text.contains("dts-hd ma") || text.contains("dtshd ma")) tags += DebridStreamAudioTag.DTS_HD_MA + if (text.contains("dts-hd") || text.contains("dtshd")) tags += DebridStreamAudioTag.DTS_HD + if (text.contains("dts-es") || text.contains("dtses")) tags += DebridStreamAudioTag.DTS_ES + if (text.hasToken("dts")) tags += DebridStreamAudioTag.DTS + if (text.contains("truehd") || text.contains("true hd")) tags += DebridStreamAudioTag.TRUEHD + if (text.hasToken("opus")) tags += DebridStreamAudioTag.OPUS + if (text.hasToken("flac")) tags += DebridStreamAudioTag.FLAC + if (text.hasToken("aac")) tags += DebridStreamAudioTag.AAC + return tags.distinct().ifEmpty { listOf(DebridStreamAudioTag.UNKNOWN) } + } + + private fun streamAudioChannels(parsedChannels: List, searchText: String): List { + val text = (parsedChannels + searchText).joinToString(" ").lowercase() + val channels = mutableListOf() + if (text.hasToken("7.1")) channels += DebridStreamAudioChannel.CH_7_1 + if (text.hasToken("6.1")) channels += DebridStreamAudioChannel.CH_6_1 + if (text.hasToken("5.1") || text.hasToken("6ch")) channels += DebridStreamAudioChannel.CH_5_1 + if (text.hasToken("2.0")) channels += DebridStreamAudioChannel.CH_2_0 + return channels.distinct().ifEmpty { listOf(DebridStreamAudioChannel.UNKNOWN) } + } + + private fun streamEncode(parsedCodec: String?, searchText: String): DebridStreamEncode { + val text = listOfNotNull(parsedCodec, searchText).joinToString(" ").lowercase() + return when { + text.hasToken("av1") -> DebridStreamEncode.AV1 + text.hasToken("hevc") || text.hasToken("h265") || text.hasToken("x265") -> DebridStreamEncode.HEVC + text.hasToken("avc") || text.hasToken("h264") || text.hasToken("x264") -> DebridStreamEncode.AVC + text.hasToken("xvid") -> DebridStreamEncode.XVID + text.hasToken("divx") -> DebridStreamEncode.DIVX + else -> DebridStreamEncode.UNKNOWN + } + } + + private fun languageFor(value: String): DebridStreamLanguage? { + val normalized = value.lowercase() + return DebridStreamLanguage.entries.firstOrNull { + normalized == it.code || normalized == it.label.lowercase() + } + } + + private fun releaseGroupFromText(text: String): String = + Regex("-([a-z0-9][a-z0-9._]{1,24})($|\\.)", RegexOption.IGNORE_CASE) + .find(text) + ?.groupValues + ?.getOrNull(1) + .orEmpty() + + private fun rank(value: T, preferred: List): Int { + val index = preferred.indexOf(value) + return if (index >= 0) index else Int.MAX_VALUE + } + + private fun rankAny(values: List, preferred: List): Int = + values.minOfOrNull { rank(it, preferred) } ?: Int.MAX_VALUE + + private fun String.hasResolutionToken(vararg tokens: String): Boolean = + Regex("(^|[^a-z0-9])(${tokens.joinToString("|")})([^a-z0-9]|\$)").containsMatchIn(this) + + private fun String.hasToken(token: String): Boolean = + Regex("(^|[^a-z0-9])${Regex.escape(token.lowercase())}([^a-z0-9]|\$)").containsMatchIn(lowercase()) + + private fun String.isDolbyVisionToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9]"), "") + return normalized == "dv" || normalized == "dovi" || normalized == "dolbyvision" + } + + private fun String.isHdrToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9+]"), "") + return normalized == "hdr" || + normalized == "hdr10" || + normalized == "hdr10+" || + normalized == "hdr10plus" || + normalized == "hlg" + } + + private fun streamSize(stream: StreamItem): Long? = + stream.clientResolve?.stream?.raw?.size ?: stream.behaviorHints.videoSize + + private fun streamSearchText(stream: StreamItem): String { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + return listOfNotNull( + stream.name, + stream.title, + stream.description, + resolve?.torrentName, + resolve?.filename, + raw?.torrentName, + raw?.filename, + parsed?.resolution, + parsed?.quality, + parsed?.codec, + parsed?.hdr?.joinToString(" "), + parsed?.audio?.joinToString(" "), + ).joinToString(" ").lowercase() + } + + private fun Int.gigabytes(): Long = this * 1_000_000_000L + + private data class StreamFacts( + val resolution: DebridStreamResolution, + val quality: DebridStreamQuality, + val visualTags: List, + val audioTags: List, + val audioChannels: List, + val encode: DebridStreamEncode, + val languages: List, + val releaseGroup: String, + val size: Long?, + val resolutionRank: Int, + val qualityRank: Int, + val visualRank: Int, + val audioRank: Int, + val channelRank: Int, + val encodeRank: Int, + val languageRank: Int, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt index 7cd0e03a..6cd5573d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt @@ -4,9 +4,20 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.StreamParser +import com.nuvio.app.features.streams.epochMs import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock private const val DIRECT_DEBRID_TAG = "DirectDebridStreams" +private const val STREAM_CACHE_TTL_MS = 5L * 60L * 1000L data class DirectDebridStreamTarget( val provider: DebridProvider, @@ -20,6 +31,10 @@ object DirectDebridStreamSource { private val log = Logger.withTag(DIRECT_DEBRID_TAG) private val encoder = DirectDebridConfigEncoder() private val formatter = DebridStreamFormatter() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val streamCache = mutableMapOf() + private val inFlightFetches = mutableMapOf>() fun configuredTargets(): List { DebridSettingsRepository.ensureLoaded() @@ -33,6 +48,12 @@ object DirectDebridStreamSource { } } + fun sourceNames(): List = + configuredTargets().map { it.addonName } + + fun isEnabled(): Boolean = + sourceNames().isNotEmpty() + fun placeholders(): List = configuredTargets().map { target -> AddonStreamGroup( @@ -43,6 +64,36 @@ object DirectDebridStreamSource { ) } + fun preloadStreams(type: String, videoId: String) { + if (type.isBlank() || videoId.isBlank()) return + configuredTargets().forEach { target -> + scope.launch { + runCatching { fetchProviderStreams(type, videoId, target) } + } + } + } + + suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult { + val targets = configuredTargets() + if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled + + val results = mutableListOf() + val errors = mutableListOf() + targets.forEach { target -> + val group = fetchProviderStreams(type, videoId, target) + when { + group.streams.isNotEmpty() -> results += group + !group.error.isNullOrBlank() -> errors += group.error + } + } + + return when { + results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results) + errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first()) + else -> DirectDebridStreamFetchResult.Empty + } + } + suspend fun fetchProviderStreams( type: String, videoId: String, @@ -54,6 +105,89 @@ object DirectDebridStreamSource { return target.emptyGroup() } + val cacheKey = DirectDebridStreamCacheKey( + providerId = target.provider.id, + type = type.trim().lowercase(), + videoId = videoId.trim(), + baseUrl = baseUrl, + settingsFingerprint = settings.toString(), + ) + cachedGroup(cacheKey)?.let { return it } + + var ownsFetch = false + val newFetch = scope.async(start = CoroutineStart.LAZY) { + fetchProviderStreamsUncached( + baseUrl = baseUrl, + type = type, + videoId = videoId, + target = target, + settings = settings, + ) + } + val activeFetch = mutex.withLock { + cachedGroupLocked(cacheKey)?.let { cached -> + return@withLock null to cached + } + val existing = inFlightFetches[cacheKey] + if (existing != null) { + existing to null + } else { + inFlightFetches[cacheKey] = newFetch + ownsFetch = true + newFetch to null + } + } + activeFetch.second?.let { + newFetch.cancel() + return it + } + val deferred = activeFetch.first ?: return target.errorGroup("Could not start Direct Debrid fetch") + if (!ownsFetch) newFetch.cancel() + if (ownsFetch) deferred.start() + + return try { + val result = deferred.await() + if (ownsFetch && result.streams.isNotEmpty() && result.error == null) { + mutex.withLock { + streamCache[cacheKey] = CachedDirectDebridStreams( + group = result, + createdAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsFetch) { + mutex.withLock { + if (inFlightFetches[cacheKey] === deferred) { + inFlightFetches.remove(cacheKey) + } + } + } + } + } + + private suspend fun cachedGroup(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? = + mutex.withLock { cachedGroupLocked(cacheKey) } + + private fun cachedGroupLocked(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? { + val cached = streamCache[cacheKey] ?: return null + val age = epochMs() - cached.createdAtMs + return if (age in 0..STREAM_CACHE_TTL_MS) { + cached.group + } else { + streamCache.remove(cacheKey) + null + } + } + + private suspend fun fetchProviderStreamsUncached( + baseUrl: String, + type: String, + videoId: String, + target: DirectDebridStreamTarget, + settings: DebridSettings, + ): AddonStreamGroup { val credential = DebridServiceCredential(target.provider, target.apiKey) val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json" return try { @@ -63,7 +197,7 @@ object DirectDebridStreamSource { addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME, addonId = target.addonId, ) - .let(DirectDebridStreamFilter::filterInstant) + .let { DirectDebridStreamFilter.filterInstant(it, settings) } .filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) } .map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) } @@ -76,13 +210,7 @@ object DirectDebridStreamSource { } catch (error: Exception) { if (error is CancellationException) throw error log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" } - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = false, - error = error.message, - ) + target.errorGroup(error.message) } } @@ -93,4 +221,33 @@ object DirectDebridStreamSource { streams = emptyList(), isLoading = false, ) + + private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup = + AddonStreamGroup( + addonName = addonName, + addonId = addonId, + streams = emptyList(), + isLoading = false, + error = message, + ) +} + +private data class DirectDebridStreamCacheKey( + val providerId: String, + val type: String, + val videoId: String, + val baseUrl: String, + val settingsFingerprint: String, +) + +private data class CachedDirectDebridStreams( + val group: AddonStreamGroup, + val createdAtMs: Long, +) + +sealed class DirectDebridStreamFetchResult { + data object Disabled : DirectDebridStreamFetchResult() + data object Empty : DirectDebridStreamFetchResult() + data class Success(val streams: List) : DirectDebridStreamFetchResult() + data class Error(val message: String) : DirectDebridStreamFetchResult() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d8bfbf27..99493f36 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -62,6 +62,7 @@ import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.components.DetailActionButtons import com.nuvio.app.features.details.components.CommentDetailSheet import com.nuvio.app.features.details.components.DetailAdditionalInfoSection @@ -372,6 +373,16 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } + val debridPreloadVideoId = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction?.videoId) { + if (meta.isSeriesLikeForDebridPreload(hasEpisodes)) { + seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id + } else { + meta.id + } + } + LaunchedEffect(meta.type, debridPreloadVideoId) { + DirectDebridStreamSource.preloadStreams(meta.type, debridPreloadVideoId) + } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -1259,3 +1270,8 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } + +private fun MetaDetails.isSeriesLikeForDebridPreload(hasEpisodes: Boolean): Boolean = + hasEpisodes || type.equals("series", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tv", ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 769456b6..f209f7f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest @@ -63,6 +64,7 @@ import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.details.components.DetailPosterRailSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.tmdb.TmdbMetadataService +import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.CurrentDateProvider import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -89,6 +91,10 @@ fun PersonDetailScreen( modifier: Modifier = Modifier, ) { var uiState by remember(personId) { mutableStateOf(PersonDetailUiState.Loading) } + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() val resolvedAvatarTransitionKey = avatarTransitionKey ?: castAvatarSharedTransitionKey(personId) LaunchedEffect(personId) { @@ -127,6 +133,7 @@ fun PersonDetailScreen( ) is PersonDetailUiState.Success -> PersonDetailContent( person = state.personDetail, + watchedKeys = watchedUiState.watchedKeys, onOpenMeta = onOpenMeta, initialProfilePhoto = initialProfilePhoto, avatarTransitionKey = resolvedAvatarTransitionKey, @@ -156,6 +163,7 @@ fun PersonDetailScreen( @OptIn(ExperimentalSharedTransitionApi::class) private fun PersonDetailContent( person: PersonDetail, + watchedKeys: Set, onOpenMeta: (MetaPreview) -> Unit, initialProfilePhoto: String? = null, avatarTransitionKey: String, @@ -274,7 +282,7 @@ private fun PersonDetailContent( DetailPosterRailSection( title = stringResource(Res.string.person_popular), items = popularCredits, - watchedKeys = emptySet(), + watchedKeys = watchedKeys, headerHorizontalPadding = 20.dp, onPosterClick = onOpenMeta, ) @@ -285,7 +293,7 @@ private fun PersonDetailContent( DetailPosterRailSection( title = stringResource(Res.string.person_latest), items = latestCredits, - watchedKeys = emptySet(), + watchedKeys = watchedKeys, headerHorizontalPadding = 20.dp, onPosterClick = onOpenMeta, ) @@ -296,7 +304,7 @@ private fun PersonDetailContent( DetailPosterRailSection( title = stringResource(Res.string.person_upcoming), items = upcomingCredits, - watchedKeys = emptySet(), + watchedKeys = watchedKeys, headerHorizontalPadding = 20.dp, onPosterClick = onOpenMeta, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 2c03a8fe..04abb4ca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -55,6 +56,7 @@ import com.nuvio.app.features.tmdb.TmdbEntityKind import com.nuvio.app.features.tmdb.TmdbEntityMediaType import com.nuvio.app.features.tmdb.TmdbEntityRailType import com.nuvio.app.features.tmdb.TmdbMetadataService +import com.nuvio.app.features.watched.WatchedRepository private sealed interface EntityBrowseUiState { data object Loading : EntityBrowseUiState @@ -75,6 +77,10 @@ fun TmdbEntityBrowseScreen( var uiState by remember(entityKind, entityId) { mutableStateOf(EntityBrowseUiState.Loading) } + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName) LaunchedEffect(entityKind, entityId) { @@ -106,6 +112,7 @@ fun TmdbEntityBrowseScreen( is EntityBrowseUiState.Success -> EntityBrowseContent( data = state.data, sourceType = sourceType, + watchedKeys = watchedUiState.watchedKeys, onOpenMeta = onOpenMeta, ) } @@ -131,6 +138,7 @@ fun TmdbEntityBrowseScreen( private fun EntityBrowseContent( data: TmdbEntityBrowseData, sourceType: String, + watchedKeys: Set, onOpenMeta: (MetaPreview) -> Unit, ) { val backgroundUrl = remember(data.rails, sourceType) { @@ -208,7 +216,7 @@ private fun EntityBrowseContent( DetailPosterRailSection( title = railTitle, items = rail.items, - watchedKeys = emptySet(), + watchedKeys = watchedKeys, headerHorizontalPadding = 20.dp, onPosterClick = onOpenMeta, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index 202af87a..c5bd40ad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -145,6 +145,7 @@ object HomeCatalogSettingsRepository { enforcePinnedCollectionsAtTop() publish() persist() + HomeRepository.applyCurrentSettings() } internal fun snapshot(): HomeCatalogSettingsSnapshot { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 4573db3c..083355a5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -1,7 +1,14 @@ package com.nuvio.app.features.home import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.fetchCatalogPage +import com.nuvio.app.features.collection.Collection +import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.collection.CollectionSource +import com.nuvio.app.features.collection.TmdbCollectionSourceResolver +import com.nuvio.app.features.collection.findCollectionCatalog +import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -27,6 +34,10 @@ object HomeRepository { private var lastRequestKey: String? = null private var currentDefinitions: List = emptyList() private var cachedSections: Map = emptyMap() + private var cachedCollectionHeroItems: List = emptyList() + private var collectionHeroJob: Job? = null + private var collectionHeroRequestKey: String? = null + private var lastPublishedCatalogHeroEmpty: Boolean = true private var lastErrorMessage: String? = null fun refresh(addons: List, force: Boolean = false) { @@ -55,10 +66,14 @@ object HomeRepository { activeRequestKey = null cachedSections = emptyMap() lastErrorMessage = null - _uiState.value = HomeUiState( + publishCurrentState( isLoading = false, - sections = emptyList(), - errorMessage = null, + requestKey = requestKey, + ) + ensureCollectionHeroFallback( + addons = addons, + force = force, + requestKey = requestKey, ) return } @@ -119,6 +134,11 @@ object HomeRepository { isLoading = false, requestKey = requestKey, ) + ensureCollectionHeroFallback( + addons = addons, + force = force, + requestKey = requestKey, + ) } } @@ -127,6 +147,11 @@ object HomeRepository { isLoading = _uiState.value.isLoading, requestKey = activeRequestKey ?: lastRequestKey, ) + ensureCollectionHeroFallback( + addons = AddonRepository.uiState.value.addons, + force = false, + requestKey = activeRequestKey ?: lastRequestKey, + ) } fun clear() { @@ -136,6 +161,11 @@ object HomeRepository { lastRequestKey = null currentDefinitions = emptyList() cachedSections = emptyMap() + cachedCollectionHeroItems = emptyList() + collectionHeroJob?.cancel() + collectionHeroJob = null + collectionHeroRequestKey = null + lastPublishedCatalogHeroEmpty = true lastErrorMessage = null _uiState.value = HomeUiState() } @@ -164,7 +194,7 @@ object HomeRepository { ) } - val heroItems = if (snapshot.heroEnabled) { + val catalogHeroItems = if (snapshot.heroEnabled) { val heroRandom = Random((requestKey?.hashCode() ?: 0).absoluteValue + 1) currentDefinitions .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } @@ -177,6 +207,12 @@ object HomeRepository { } else { emptyList() } + lastPublishedCatalogHeroEmpty = snapshot.heroEnabled && catalogHeroItems.isEmpty() + val heroItems = if (snapshot.heroEnabled) { + catalogHeroItems.ifEmpty { cachedCollectionHeroItems } + } else { + emptyList() + } _uiState.value = HomeUiState( isLoading = isLoading, @@ -222,9 +258,175 @@ object HomeRepository { supportsPagination = supportsPagination, ) } + + private fun ensureCollectionHeroFallback( + addons: List, + force: Boolean, + requestKey: String?, + ) { + if (!lastPublishedCatalogHeroEmpty) return + val snapshot = HomeCatalogSettingsRepository.snapshot() + if (!snapshot.heroEnabled) return + val collections = enabledCollectionsForHero(snapshot) + if (collections.isEmpty()) { + cachedCollectionHeroItems = emptyList() + collectionHeroRequestKey = null + return + } + + val nextRequestKey = collectionHeroRequestKey( + collections = collections, + addons = addons, + snapshot = snapshot, + requestKey = requestKey, + ) + if (!force && collectionHeroRequestKey == nextRequestKey) return + + collectionHeroJob?.cancel() + collectionHeroRequestKey = nextRequestKey + cachedCollectionHeroItems = emptyList() + publishCurrentState( + isLoading = _uiState.value.isLoading, + requestKey = requestKey, + ) + + collectionHeroJob = scope.launch { + val sources = collectionHeroSources(collections) + val sourceResults = sources.map { source -> + async { + runCatching { + source.resolveCollectionHeroItems(addons) + }.getOrDefault(emptyList()) + } + }.awaitAll() + val random = Random((nextRequestKey.hashCode()).absoluteValue + 7) + cachedCollectionHeroItems = roundRobinCollectionHeroItems(sourceResults) + .distinctBy { item -> item.stableKey() } + .shuffled(random) + .take(HOME_HERO_ITEM_LIMIT) + publishCurrentState( + isLoading = _uiState.value.isLoading, + requestKey = requestKey, + ) + } + } + + private fun enabledCollectionsForHero(snapshot: HomeCatalogSettingsSnapshot): List { + val preferences = snapshot.preferences + return CollectionRepository.collections.value + .filter { collection -> + collection.folders.isNotEmpty() && + preferences["collection_${collection.id}"]?.enabled != false + } + .sortedBy { collection -> + preferences["collection_${collection.id}"]?.order ?: Int.MAX_VALUE + } + } + + private fun collectionHeroSources(collections: List): List = + collections + .flatMap { collection -> collection.folders } + .flatMap { folder -> folder.resolvedSources } + .take(HOME_COLLECTION_HERO_SOURCE_LIMIT) + + private suspend fun CollectionSource.resolveCollectionHeroItems(addons: List): List { + val page = when { + isTmdb -> TmdbCollectionSourceResolver.resolve(source = this, page = 1) + isTrakt -> TraktPublicListSourceResolver.resolve(source = this, page = 1) + else -> { + val catalogSource = addonCatalogSource() ?: return emptyList() + val resolvedCatalog = addons.findCollectionCatalog(catalogSource) ?: return emptyList() + fetchCatalogPage( + manifestUrl = resolvedCatalog.addon.manifestUrl, + type = catalogSource.type, + catalogId = catalogSource.catalogId, + genre = catalogSource.genre, + maxItems = HOME_COLLECTION_HERO_SOURCE_ITEM_LIMIT, + ) + } + } + val items = page.items + return if (HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) { + items.filterReleasedItems(CurrentDateProvider.todayIsoDate()) + } else { + items + } + } + + private fun roundRobinCollectionHeroItems(sourceResults: List>): List { + val iterators = sourceResults.filter { it.isNotEmpty() }.map { it.iterator() } + if (iterators.isEmpty()) return emptyList() + val merged = mutableListOf() + var hasMore = true + while (hasMore && merged.size < HOME_COLLECTION_HERO_SOURCE_LIMIT * HOME_COLLECTION_HERO_SOURCE_ITEM_LIMIT) { + hasMore = false + iterators.forEach { iterator -> + if (iterator.hasNext()) { + merged.add(iterator.next()) + hasMore = true + } + } + } + return merged + } + + private fun collectionHeroRequestKey( + collections: List, + addons: List, + snapshot: HomeCatalogSettingsSnapshot, + requestKey: String?, + ): String = buildString { + append(requestKey.orEmpty()) + append("|hideUnreleased=") + append(snapshot.hideUnreleasedContent) + append("|collections=") + collections.forEach { collection -> + val preference = snapshot.preferences["collection_${collection.id}"] + append(collection.id) + append(":") + append(preference?.order ?: Int.MAX_VALUE) + append(":") + collection.folders.forEach { folder -> + append(folder.id) + append("[") + folder.resolvedSources.forEach { source -> + append(collectionSourceKey(source)) + append(",") + } + append("]") + } + append(";") + } + append("|addons=") + addons.forEach { addon -> + append(addon.manifest?.id.orEmpty()) + append(":") + append(addon.manifestUrl) + append(":") + append(addon.manifest?.catalogs?.size ?: 0) + append(";") + } + } + + private fun collectionSourceKey(source: CollectionSource): String = + listOf( + source.provider, + source.addonId, + source.type, + source.catalogId, + source.genre, + source.tmdbSourceType, + source.tmdbId?.toString(), + source.traktListId?.toString(), + source.mediaType, + source.sortBy, + source.sortHow, + ).joinToString(":") { it.orEmpty() } } private const val HOME_HERO_ITEM_LIMIT = 8 +private const val HOME_COLLECTION_HERO_SOURCE_LIMIT = 6 +private const val HOME_COLLECTION_HERO_SOURCE_ITEM_LIMIT = 8 private const val HOME_CATALOG_FETCH_BATCH_SIZE = 4 private const val HOME_CATALOG_PREVIEW_FETCH_LIMIT = 18 private const val HOME_CATALOG_PUBLISH_INTERVAL = 2 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index aa4be057..3bf4715b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -35,6 +35,7 @@ import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.shouldUseTraktProgress +import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem @@ -45,13 +46,19 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching +import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.WatchProgressSourceLocal +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle +import com.nuvio.app.features.watchprogress.continueWatchingEntries import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem -import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.collection.CollectionRepository @@ -164,46 +171,100 @@ fun HomeScreen( if (isTraktProgressActive) emptyList() else watchedUiState.items } - val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) { - WatchingState.latestCompletedBySeries( - progressEntries = effectiveWatchProgressEntries, + val allNextUpSeedEntries = remember( + watchProgressUiState.entries, + effectiveWatchedItems, + isTraktProgressActive, + continueWatchingPreferences.upNextFromFurthestEpisode, + ) { + buildTvParityNextUpSeedEntries( + progressEntries = watchProgressUiState.entries, watchedItems = effectiveWatchedItems, + isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, + nowEpochMs = WatchProgressClock.nowEpochMs(), ) } - val completedSeriesCandidates = remember(latestCompletedBySeries) { - latestCompletedBySeries.map { (content, completed) -> + + val recentNextUpSeedEntries = remember( + allNextUpSeedEntries, + isTraktProgressActive, + traktSettingsUiState.continueWatchingDaysCap, + ) { + filterEntriesForTraktContinueWatchingWindow( + entries = allNextUpSeedEntries, + isTraktProgressActive = isTraktProgressActive, + daysCap = traktSettingsUiState.continueWatchingDaysCap, + nowEpochMs = WatchProgressClock.nowEpochMs(), + ) + } + + val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) { + allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId } + } + + val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) { + allNextUpSeedEntries.mapNotNull { entry -> + val season = entry.seasonNumber ?: return@mapNotNull null + val episode = entry.episodeNumber ?: return@mapNotNull null + entry.parentMetaId to (season to episode) + }.toMap() + } + + val visibleContinueWatchingEntries = remember(effectiveWatchProgressEntries) { + effectiveWatchProgressEntries.continueWatchingEntries() + } + + val latestCompletedAtBySeries = remember(allNextUpSeedEntries) { + allNextUpSeedEntries + .groupBy { entry -> entry.parentMetaId } + .mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE } + } + + val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) { + visibleContinueWatchingEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .filter { entry -> + shouldTreatAsActiveInProgressForNextUpSuppression( + progress = entry, + latestCompletedAt = latestCompletedAtBySeries[entry.parentMetaId], + ) + } + .map { entry -> entry.parentMetaId } + .filter(String::isNotBlank) + .toSet() + } + + val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) { + recentNextUpSeedEntries.mapNotNull { seed -> + val season = seed.seasonNumber ?: return@mapNotNull null + val episode = seed.episodeNumber ?: return@mapNotNull null + if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null CompletedSeriesCandidate( - content = content, - seasonNumber = completed.seasonNumber, - episodeNumber = completed.episodeNumber, - markedAtEpochMs = completed.markedAtEpochMs, + content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId), + seasonNumber = season, + episodeNumber = episode, + markedAtEpochMs = seed.lastUpdatedEpochMs, ) } } - val completedSeriesContentIds = remember(completedSeriesCandidates) { - completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } - } - val visibleContinueWatchingEntries = remember( - effectiveWatchProgressEntries, - latestCompletedBySeries, - ) { - WatchingState.visibleContinueWatchingEntries( - progressEntries = effectiveWatchProgressEntries, - latestCompletedBySeries = latestCompletedBySeries, - ) - } val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf>>(emptyMap()) } + var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf>(emptySet()) } val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } val cachedNextUpItems = remember( cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys, - completedSeriesContentIds, + activeNextUpSeedContentIds, + currentNextUpSeedByContentId, isTraktProgressActive, + watchProgressUiState.hasLoadedRemoteProgress, + processedNextUpContentIds, + nextUpItemsBySeries, continueWatchingPreferences.showUnairedNextUp, watchedUiState.isLoaded, ) { @@ -211,7 +272,32 @@ fun HomeScreen( if ( !isTraktProgressActive && watchedUiState.isLoaded && - cached.contentId !in completedSeriesContentIds + cached.contentId !in activeNextUpSeedContentIds + ) { + return@mapNotNull null + } + if ( + isTraktProgressActive && + watchProgressUiState.hasLoadedRemoteProgress && + cached.contentId !in activeNextUpSeedContentIds + ) { + return@mapNotNull null + } + val currentSeed = currentNextUpSeedByContentId[cached.contentId] + if ( + currentSeed != null && + cached.seedSeason != null && + cached.seedEpisode != null + ) { + val (currentSeason, currentEpisode) = currentSeed + val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode + if (seedChanged) return@mapNotNull null + } + if ( + isTraktProgressActive && + watchProgressUiState.hasLoadedRemoteProgress && + cached.contentId in processedNextUpContentIds && + cached.contentId !in nextUpItemsBySeries.keys ) { return@mapNotNull null } @@ -257,12 +343,14 @@ fun HomeScreen( visibleContinueWatchingEntries, cachedInProgressItems, effectivNextUpItems, + nextUpSuppressedSeriesIds, continueWatchingPreferences.sortMode, ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, cachedInProgressByVideoId = cachedInProgressItems, nextUpItemsBySeries = effectivNextUpItems, + nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds, sortMode = continueWatchingPreferences.sortMode, todayIsoDate = CurrentDateProvider.todayIsoDate(), ) @@ -304,31 +392,69 @@ fun HomeScreen( LaunchedEffect( completedSeriesCandidates, + cachedNextUpItems, + visibleContinueWatchingEntries, metaProviderKey, continueWatchingPreferences.showUnairedNextUp, ) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() + processedNextUpContentIds = emptySet() return@LaunchedEffect } - if (metaProviderKey.isEmpty()) return@LaunchedEffect + val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate -> + val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null + val item = cached.second + if ( + item.nextUpSeedSeasonNumber != candidate.seasonNumber || + item.nextUpSeedEpisodeNumber != candidate.episodeNumber + ) { + return@mapNotNull null + } + candidate.content.id to cached + }.toMap() + val candidatesToResolve = completedSeriesCandidates.filter { candidate -> + candidate.content.id !in cachedResolvedNextUpItems + } + if (candidatesToResolve.isEmpty()) { + nextUpItemsBySeries = cachedResolvedNextUpItems + processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> + candidate.content.id + } + saveContinueWatchingSnapshots( + nextUpItemsBySeries = cachedResolvedNextUpItems, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = CurrentDateProvider.todayIsoDate(), + ) + return@LaunchedEffect + } + + if (metaProviderKey.isEmpty()) { + return@LaunchedEffect + } val todayIsoDate = CurrentDateProvider.todayIsoDate() val semaphore = Semaphore(4) - val results = completedSeriesCandidates.map { completedEntry -> + val freshResults = candidatesToResolve.map { completedEntry -> async { semaphore.withPermit { val meta = MetaDetailsRepository.fetch( type = completedEntry.content.type, id = completedEntry.content.id, - ) ?: return@withPermit null + ) + if (meta == null) { + return@withPermit null + } val nextEpisode = meta.nextReleasedEpisodeAfter( seasonNumber = completedEntry.seasonNumber, episodeNumber = completedEntry.episodeNumber, todayIsoDate = todayIsoDate, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, - ) ?: return@withPermit null + ) + if (nextEpisode == null) { + return@withPermit null + } val item = completedEntry.toContinueWatchingSeed(meta) .toUpNextContinueWatchingItem(nextEpisode) if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in continueWatchingPreferences.dismissedNextUpKeys) { @@ -338,56 +464,16 @@ fun HomeScreen( } } }.awaitAll().filterNotNull().toMap() + val results = cachedResolvedNextUpItems + freshResults nextUpItemsBySeries = results + processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> + candidate.content.id + } - val nextUpCache = results.mapNotNull { (contentId, pair) -> - val item = pair.second - CachedNextUpItem( - contentId = contentId, - contentType = item.parentMetaType, - name = item.title, - poster = item.poster, - backdrop = item.background, - logo = item.logo, - videoId = item.videoId, - season = item.seasonNumber, - episode = item.episodeNumber, - episodeTitle = item.episodeTitle, - episodeThumbnail = item.episodeThumbnail, - pauseDescription = item.pauseDescription, - released = item.released, - hasAired = item.released?.let { released -> - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released) - } ?: true, - lastWatched = pair.first, - sortTimestamp = pair.first, - seedSeason = item.nextUpSeedSeasonNumber, - seedEpisode = item.nextUpSeedEpisodeNumber, - ) - } - val inProgressCache = visibleContinueWatchingEntries.map { entry -> - CachedInProgressItem( - contentId = entry.parentMetaId, - contentType = entry.contentType, - name = entry.title, - poster = entry.poster, - backdrop = entry.background, - logo = entry.logo, - videoId = entry.videoId, - season = entry.seasonNumber, - episode = entry.episodeNumber, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - pauseDescription = entry.pauseDescription, - position = entry.lastPositionMs, - duration = entry.durationMs, - lastWatched = entry.lastUpdatedEpochMs, - progressPercent = entry.progressPercent, - ) - } - ContinueWatchingEnrichmentCache.saveSnapshots( - nextUp = nextUpCache, - inProgress = inProgressCache, + saveContinueWatchingSnapshots( + nextUpItemsBySeries = results, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, ) } @@ -615,6 +701,7 @@ fun HomeScreen( private const val HOME_CATALOG_PREVIEW_LIMIT = 18 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L +private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L internal fun filterEntriesForTraktContinueWatchingWindow( entries: List, @@ -630,6 +717,169 @@ internal fun filterEntriesForTraktContinueWatchingWindow( return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } } +private fun buildTvParityNextUpSeedEntries( + progressEntries: List, + watchedItems: List, + isTraktProgressActive: Boolean, + preferFurthestEpisode: Boolean, + nowEpochMs: Long, +): List { + val rawSeeds = if (isTraktProgressActive) { + progressEntries.asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } + .filter { entry -> shouldUseAsTraktNextUpSeed(entry, nowEpochMs) } + .toList() + } else { + watchedItems.asSequence() + .filter { item -> item.type.isSeriesTypeForContinueWatching() } + .filter { item -> item.season != null && item.episode != null && item.season != 0 } + .filter { item -> !isMalformedNextUpSeedContentId(item.id) } + .map { item -> item.toNextUpSeedEntry() } + .toList() + } + + return if (isTraktProgressActive) { + mergeTvTraktNextUpSeeds(rawSeeds) + } else { + rawSeeds + .groupBy { entry -> nextUpSeedKey(entry) } + .mapNotNull { (_, entries) -> + choosePreferredNextUpSeed( + entries = entries, + preferFurthestEpisode = preferFurthestEpisode, + ) + } + .sortedByDescending { entry -> entry.lastUpdatedEpochMs } + } +} + +private fun shouldUseAsTraktNextUpSeed( + entry: WatchProgressEntry, + nowEpochMs: Long, +): Boolean { + if (!entry.shouldUseAsCompletedSeedForContinueWatching()) return false + if (entry.source != WatchProgressSourceTraktPlayback) return true + + val ageMs = nowEpochMs - entry.lastUpdatedEpochMs + return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS +} + +private fun WatchedItem.toNextUpSeedEntry(): WatchProgressEntry = + WatchProgressEntry( + contentType = type, + parentMetaId = id, + parentMetaType = type, + videoId = id, + title = name, + poster = poster, + seasonNumber = season, + episodeNumber = episode, + lastPositionMs = 1L, + durationMs = 1L, + lastUpdatedEpochMs = markedAtEpochMs, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceLocal, + ) + +private fun nextUpSeedKey(entry: WatchProgressEntry): String = + entry.parentMetaId.trim() + +private fun mergeTvTraktNextUpSeeds(entries: List): List { + val merged = linkedMapOf() + entries + .filter { entry -> entry.source == WatchProgressSourceTraktShowProgress } + .forEach { seed -> + merged[nextUpSeedKey(seed)] = seed + } + entries + .filter { entry -> entry.source == WatchProgressSourceTraktHistory || entry.source == WatchProgressSourceTraktPlayback } + .forEach { seed -> + val key = nextUpSeedKey(seed) + val existing = merged[key] + if (existing == null || shouldReplaceNextUpSeed(existing, seed)) { + merged[key] = seed + } + } + return merged.values.sortedByDescending { entry -> entry.lastUpdatedEpochMs } +} + +private fun shouldReplaceNextUpSeed( + existing: WatchProgressEntry, + candidate: WatchProgressEntry, +): Boolean { + val candidateSeason = candidate.seasonNumber ?: -1 + val candidateEpisode = candidate.episodeNumber ?: -1 + val existingSeason = existing.seasonNumber ?: -1 + val existingEpisode = existing.episodeNumber ?: -1 + return candidateSeason > existingSeason || + ( + candidateSeason == existingSeason && + ( + candidateEpisode > existingEpisode || + ( + candidateEpisode == existingEpisode && + candidate.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs + ) + ) + ) +} + +private fun choosePreferredNextUpSeed( + entries: List, + preferFurthestEpisode: Boolean, +): WatchProgressEntry? { + if (entries.isEmpty()) return null + val bestRank = entries.minOf(::nextUpSeedSourceRank) + return entries + .asSequence() + .filter { entry -> nextUpSeedSourceRank(entry) == bestRank } + .maxWithOrNull( + if (preferFurthestEpisode) { + compareBy( + { it.seasonNumber ?: -1 }, + { it.episodeNumber ?: -1 }, + { it.lastUpdatedEpochMs }, + ) + } else { + compareBy( + { it.lastUpdatedEpochMs }, + { it.seasonNumber ?: -1 }, + { it.episodeNumber ?: -1 }, + ) + }, + ) +} + +private fun nextUpSeedSourceRank(entry: WatchProgressEntry): Int = + when (entry.source) { + WatchProgressSourceTraktPlayback, + WatchProgressSourceTraktShowProgress, + -> 0 + WatchProgressSourceTraktHistory -> 1 + WatchProgressSourceLocal -> 2 + else -> 4 + } + +private fun shouldTreatAsActiveInProgressForNextUpSuppression( + progress: WatchProgressEntry, + latestCompletedAt: Long?, +): Boolean { + if (!progress.shouldTreatAsInProgressForContinueWatching()) return false + if (latestCompletedAt == null || latestCompletedAt == Long.MIN_VALUE) return true + return progress.lastUpdatedEpochMs >= latestCompletedAt +} + +private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { + val trimmed = contentId?.trim().orEmpty() + if (trimmed.isEmpty()) return true + return when (trimmed.lowercase()) { + "tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true + else -> false + } +} + private fun heroMobileBelowSectionHeightHint( maxWidthDp: Float, continueWatchingVisible: Boolean, @@ -652,15 +902,17 @@ internal fun buildHomeContinueWatchingItems( visibleEntries: List, cachedInProgressByVideoId: Map = emptyMap(), nextUpItemsBySeries: Map>, + nextUpSuppressedSeriesIds: Set? = null, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, todayIsoDate: String = "", ): List { - val inProgressSeriesIds = visibleEntries - .asSequence() - .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } - .map { entry -> entry.parentMetaId } - .filter(String::isNotBlank) - .toSet() + val suppressedSeriesIds = nextUpSuppressedSeriesIds + ?: visibleEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .map { entry -> entry.parentMetaId } + .filter(String::isNotBlank) + .toSet() val candidates = buildList { addAll( @@ -675,7 +927,7 @@ internal fun buildHomeContinueWatchingItems( ) addAll( nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) -> - if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null + if (item.parentMetaId in suppressedSeriesIds) return@mapNotNull null HomeContinueWatchingCandidate( lastUpdatedEpochMs = lastUpdatedEpochMs, item = item, @@ -755,6 +1007,62 @@ private data class HomeContinueWatchingCandidate( val isProgressEntry: Boolean, ) +private fun saveContinueWatchingSnapshots( + nextUpItemsBySeries: Map>, + visibleContinueWatchingEntries: List, + todayIsoDate: String, +) { + val nextUpCache = nextUpItemsBySeries.mapNotNull { (contentId, pair) -> + val item = pair.second + CachedNextUpItem( + contentId = contentId, + contentType = item.parentMetaType, + name = item.title, + poster = item.poster, + backdrop = item.background, + logo = item.logo, + videoId = item.videoId, + season = item.seasonNumber, + episode = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + released = item.released, + hasAired = item.released?.let { released -> + isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released) + } ?: true, + lastWatched = pair.first, + sortTimestamp = pair.first, + seedSeason = item.nextUpSeedSeasonNumber, + seedEpisode = item.nextUpSeedEpisodeNumber, + ) + } + val inProgressCache = visibleContinueWatchingEntries.map { entry -> + CachedInProgressItem( + contentId = entry.parentMetaId, + contentType = entry.contentType, + name = entry.title, + poster = entry.poster, + backdrop = entry.background, + logo = entry.logo, + videoId = entry.videoId, + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = entry.episodeTitle, + episodeThumbnail = entry.episodeThumbnail, + pauseDescription = entry.pauseDescription, + position = entry.lastPositionMs, + duration = entry.durationMs, + lastWatched = entry.lastUpdatedEpochMs, + progressPercent = entry.progressPercent, + ) + } + ContinueWatchingEnrichmentCache.saveSnapshots( + nextUp = nextUpCache, + inProgress = inProgressCache, + ) +} + private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.features.details.MetaDetails) = WatchProgressEntry( contentType = content.type, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index c200b6af..6037ce7b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -159,37 +160,60 @@ private fun HomeContinueWatchingSectionContent( HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() - NuvioShelfSection( - title = stringResource(Res.string.compose_settings_page_continue_watching), - entries = items, - modifier = modifier, - headerHorizontalPadding = sectionPadding, - rowContentPadding = PaddingValues(horizontal = sectionPadding), - itemSpacing = layout.itemGap, - showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, - key = { item -> item.videoId }, - ) { item -> - when (style) { - ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) - ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) + val itemOrderKey = remember(items) { + items.joinToString(separator = "|") { item -> item.continueWatchingRowOrderKey() } + } + + key(itemOrderKey) { + NuvioShelfSection( + title = stringResource(Res.string.compose_settings_page_continue_watching), + entries = items, + modifier = modifier, + headerHorizontalPadding = sectionPadding, + rowContentPadding = PaddingValues(horizontal = sectionPadding), + itemSpacing = layout.itemGap, + showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, + key = { item -> item.videoId }, + ) { item -> + when (style) { + ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) + ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) + } } } } +private fun ContinueWatchingItem.continueWatchingRowOrderKey(): String = + buildString { + append(if (isNextUp) "next" else "progress") + append(':') + append(parentMetaId) + append(':') + append(videoId) + append(':') + append(seasonNumber) + append('x') + append(episodeNumber) + append(":seed=") + append(nextUpSeedSeasonNumber) + append('x') + append(nextUpSeedEpisodeNumber) + } + @Composable fun ContinueWatchingStylePreview( style: ContinueWatchingSectionStyle, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index dc75e101..863fa3b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -32,6 +32,8 @@ import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch @@ -50,6 +52,10 @@ fun LibraryScreen( LibraryRepository.ensureLoaded() LibraryRepository.uiState }.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() @@ -176,6 +182,7 @@ fun LibraryScreen( else -> { librarySections( sections = uiState.sections, + watchedKeys = watchedUiState.watchedKeys, onPosterClick = onPosterClick, onSectionViewAllClick = onSectionViewAllClick, onPosterLongClick = onPosterLongClick, @@ -187,6 +194,7 @@ fun LibraryScreen( private fun LazyListScope.librarySections( sections: List, + watchedKeys: Set, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, @@ -209,8 +217,13 @@ private fun LazyListScope.librarySections( viewAllPillSize = NuvioViewAllPillSize.Compact, key = { item -> "${item.type}:${item.id}" }, ) { item -> + val posterItem = item.toMetaPreview() HomePosterCard( - item = item.toMetaPreview(), + item = posterItem, + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = posterItem, + ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item, section) } }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt new file mode 100644 index 00000000..adb17d13 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt @@ -0,0 +1,317 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt + +@Composable +internal fun IosVideoSettingsModal( + visible: Boolean, + settings: PlayerSettingsUiState, + onSettingsChanged: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val colorScheme = MaterialTheme.colorScheme + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(200)), + ) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onDismiss, + ) + .background(colorScheme.scrim.copy(alpha = 0.56f)), + contentAlignment = Alignment.Center, + ) { + val maxH = maxHeight + AnimatedVisibility( + visible = visible, + enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), + exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + ) { + Column( + modifier = Modifier + .widthIn(max = 460.dp) + .fillMaxWidth(0.92f) + .heightIn(max = maxH * 0.95f) + .clip(RoundedCornerShape(24.dp)) + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = {}, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Video", + color = colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { + PlayerSettingsRepository.resetIosVideoOutputTuning() + onSettingsChanged() + }) { + Text("Reset tuning") + } + } + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + OptionGroup( + title = "Output preset", + options = IosVideoOutputPreset.entries, + selected = settings.iosVideoOutputPreset, + label = { it.label }, + description = { it.description }, + onSelect = { + PlayerSettingsRepository.setIosVideoOutputPreset(it) + onSettingsChanged() + }, + ) + + ToggleRow( + title = "HDR peak detection", + description = "Estimate HDR peak brightness when metadata is bad or missing.", + checked = settings.iosHdrComputePeakEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) + onSettingsChanged() + }, + ) + + OptionGroup( + title = "Tone mapping", + options = IosToneMappingMode.entries, + selected = settings.iosToneMappingMode, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosToneMappingMode(it) + onSettingsChanged() + }, + ) + + ToggleRow( + title = "Deband", + description = "Reduce color banding at a small performance cost.", + checked = settings.iosDebandEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosDebandEnabled(it) + onSettingsChanged() + }, + ) + ToggleRow( + title = "Frame interpolation", + description = "Smooth motion when mpv can use display sync cleanly.", + checked = settings.iosInterpolationEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosInterpolationEnabled(it) + onSettingsChanged() + }, + ) + + PictureSlider( + title = "Brightness", + value = settings.iosBrightness, + onValueChanged = { + PlayerSettingsRepository.setIosBrightness(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = "Contrast", + value = settings.iosContrast, + onValueChanged = { + PlayerSettingsRepository.setIosContrast(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = "Saturation", + value = settings.iosSaturation, + onValueChanged = { + PlayerSettingsRepository.setIosSaturation(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = "Gamma", + value = settings.iosGamma, + onValueChanged = { + PlayerSettingsRepository.setIosGamma(it) + onSettingsChanged() + }, + ) + } + } + } + } + } +} + +@Composable +private fun ToggleRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text(text = title, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold) + Text(text = description, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun PictureSlider( + title: String, + value: Int, + onValueChanged: (Int) -> Unit, +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text(text = value.toString(), color = MaterialTheme.colorScheme.primary) + } + Slider( + value = value.toFloat(), + onValueChange = { onValueChanged(it.roundToInt().coerceIn(-50, 50)) }, + valueRange = -50f..50f, + steps = 99, + ) + } +} + +@Composable +private fun OptionGroup( + title: String, + options: List, + selected: T, + label: (T) -> String, + description: ((T) -> String)? = null, + onSelect: (T) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + options.forEach { option -> + val isSelected = option == selected + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(option) }, + color = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + }, + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label(option), color = MaterialTheme.colorScheme.onSurface) + val subtitle = description?.invoke(option) + if (!subtitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + } + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 540ed57b..4abec385 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Build import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Forward10 import androidx.compose.material.icons.rounded.Lock @@ -83,6 +84,7 @@ internal fun PlayerControlsShell( onSpeedClick: () -> Unit, onSubtitleClick: () -> Unit, onAudioClick: () -> Unit, + onVideoSettingsClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, onSubmitIntroClick: (() -> Unit)? = null, @@ -145,6 +147,7 @@ internal fun PlayerControlsShell( showParentalGuide = showParentalGuide, onParentalGuideAnimationComplete = onParentalGuideAnimationComplete, onLockToggle = onLockToggle, + onVideoSettingsClick = onVideoSettingsClick, onBack = onBack, modifier = Modifier .align(Alignment.TopStart) @@ -211,6 +214,7 @@ private fun PlayerHeader( showParentalGuide: Boolean, onParentalGuideAnimationComplete: () -> Unit, onLockToggle: () -> Unit, + onVideoSettingsClick: (() -> Unit)?, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -321,6 +325,15 @@ private fun PlayerHeader( iconSize = metrics.headerIconSize, onClick = onLockToggle, ) + if (onVideoSettingsClick != null) { + PlayerHeaderIconButton( + icon = Icons.Rounded.Build, + contentDescription = "Video settings", + buttonSize = metrics.headerIconSize + 16.dp, + iconSize = metrics.headerIconSize, + onClick = onVideoSettingsClick, + ) + } NuvioBackButton( onClick = onBack, containerColor = Color.Black.copy(alpha = 0.35f), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 8a5b6730..ac0be69f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -18,6 +18,7 @@ interface PlayerEngineController { fun clearExternalSubtitle() fun clearExternalSubtitleAndSelect(trackIndex: Int) fun applySubtitleStyle(style: SubtitleStyleState) {} + fun configureIosVideoOutput(settings: PlayerSettingsUiState) {} } internal fun sanitizePlaybackHeaders(headers: Map?): Map { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index f659084a..773a276d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -62,6 +62,73 @@ enum class PlayerResizeMode { Zoom, } +enum class IosVideoOutputPreset( + val label: String, + val description: String, +) { + NativeEdr( + label = "Native EDR", + description = "Best for HDR-capable iPhones and iPads.", + ), + SdrToneMapped( + label = "SDR tone mapped", + description = "More predictable whites and blacks on SDR-style output.", + ), + Compatibility( + label = "Compatibility", + description = "Closest to the older iOS MPV behavior.", + ), + Custom( + label = "Custom", + description = "Use your advanced values below.", + ), +} + +enum class IosToneMappingMode( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + Bt2390("bt.2390", "BT.2390"), + Mobius("mobius", "Mobius"), + Reinhard("reinhard", "Reinhard"), + Hable("hable", "Hable"), + Gamma("gamma", "Gamma"), + Clip("clip", "Clip"), +} + +enum class IosTargetPrimaries( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + Bt709("bt.709", "BT.709"), + DisplayP3("display-p3", "Display P3"), + Bt2020("bt.2020", "BT.2020"), +} + +enum class IosTargetTransfer( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + Srgb("srgb", "sRGB"), + Bt1886("bt.1886", "BT.1886"), + Gamma22("gamma2.2", "Gamma 2.2"), + Gamma24("gamma2.4", "Gamma 2.4"), + Pq("pq", "PQ"), + Hlg("hlg", "HLG"), +} + +enum class IosHardwareDecoderMode( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + VideoToolbox("videotoolbox", "VideoToolbox"), + Off("no", "Off"), +} + data class PlayerPlaybackSnapshot( val isLoading: Boolean = true, val isPlaying: Boolean = false, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 34ac6e92..a18b195d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -56,8 +56,10 @@ import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules import com.nuvio.app.features.player.skip.SkipIntroButton import com.nuvio.app.features.player.skip.SkipIntroRepository import com.nuvio.app.features.player.skip.SkipInterval +import com.nuvio.app.features.streams.BingeGroupCacheRepository import com.nuvio.app.features.streams.StreamAutoPlayMode import com.nuvio.app.features.streams.StreamAutoPlaySelector +import com.nuvio.app.features.streams.StreamAutoPlaySource import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsUiState @@ -68,10 +70,12 @@ import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import com.nuvio.app.isIos import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import kotlin.math.abs @@ -85,6 +89,8 @@ private const val PlayerLockedOverlayDurationMs = 2_000L private const val PlayerLeftGestureBoundary = 0.4f private const val PlayerRightGestureBoundary = 0.6f private const val PlayerVerticalGestureSensitivity = 1f +/** Hard ceiling for next-episode stream search to prevent hanging forever. */ +private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L private val PlayerSliderOverlayGap = 12.dp private val PlayerTimeRowHeight = 36.dp private val PlayerActionRowHeight = 50.dp @@ -322,6 +328,15 @@ fun PlayerScreen( } } + // Persist binge group per content so subsequent episode plays + // (from CW, Details, or next-episode) can reuse the same source group. + LaunchedEffect(currentStreamBingeGroup, parentMetaId) { + val bg = currentStreamBingeGroup + if (bg != null && parentMetaId.isNotBlank()) { + BingeGroupCacheRepository.save(parentMetaId, bg) + } + } + ManagePlayerPictureInPicture( isPlaying = playbackSnapshot.isPlaying, playerSize = layoutSize, @@ -472,6 +487,7 @@ fun PlayerScreen( var showAudioModal by remember { mutableStateOf(false) } var showSubtitleModal by remember { mutableStateOf(false) } + var showVideoSettingsModal by remember { mutableStateOf(false) } var audioTracks by remember { mutableStateOf>(emptyList()) } var subtitleTracks by remember { mutableStateOf>(emptyList()) } var selectedAudioIndex by remember { mutableStateOf(-1) } @@ -609,6 +625,7 @@ fun PlayerScreen( renderedGestureFeedback = null showAudioModal = false showSubtitleModal = false + showVideoSettingsModal = false showSourcesPanel = false showEpisodesPanel = false episodeStreamsPanelState = EpisodeStreamsPanelState() @@ -1098,6 +1115,12 @@ fun PlayerScreen( settings.streamAutoPlayPreferBingeGroup ) + // bingeGroupOnly manual mode: only binge group preference is active (not next-episode toggle) + val bingeGroupOnlyManualMode = + shouldAutoSelectInManualMode && + !settings.streamAutoPlayNextEpisodeEnabled && + settings.streamAutoPlayPreferBingeGroup + // Determine auto-play mode for next episode val effectiveMode = if (shouldAutoSelectInManualMode) { StreamAutoPlayMode.FIRST_STREAM @@ -1105,7 +1128,7 @@ fun PlayerScreen( settings.streamAutoPlayMode } val effectiveSource = if (shouldAutoSelectInManualMode) { - com.nuvio.app.features.streams.StreamAutoPlaySource.ALL_SOURCES + StreamAutoPlaySource.ALL_SOURCES } else { settings.streamAutoPlaySource } @@ -1125,6 +1148,13 @@ fun PlayerScreen( settings.streamAutoPlayRegex } + // Determine preferred binge group from current stream (not cache) + val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { + currentStreamBingeGroup + } else { + null + } + nextEpisodeAutoPlayJob = scope.launch { PlayerStreamsRepository.loadEpisodeStreams( type = type, @@ -1137,59 +1167,171 @@ fun PlayerScreen( .map { it.displayTitle } .toSet() - val timeoutMs = settings.streamAutoPlayTimeoutSeconds * 1000L - val startTime = WatchProgressClock.nowEpochMs() + val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds + val isUnlimitedTimeout = timeoutSeconds == Int.MAX_VALUE + var autoSelectTriggered = false + var timeoutElapsed = false + var selectedStream: StreamItem? = null - // Collect streams as they arrive - PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> - if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest + // Full select: tries binge group first, then falls back to mode-based selection + fun trySelectStream(streams: List): StreamItem? { + return StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, + bingeGroupOnly = bingeGroupOnlyManualMode, + ) + } - val allStreams = state.groups.flatMap { it.streams } - val elapsed = WatchProgressClock.nowEpochMs() - startTime + // Binge group only early match: returns null if no binge group match + fun tryBingeGroupOnly(streams: List): StreamItem? { + if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null + return StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = true, + bingeGroupOnly = true, + ) + } - val selected = if (allStreams.isNotEmpty()) { - StreamAutoPlaySelector.selectAutoPlayStream( - streams = allStreams, - mode = effectiveMode, - regexPattern = effectiveRegex, - source = effectiveSource, - installedAddonNames = installedAddonNames, - selectedAddons = effectiveSelectedAddons, - selectedPlugins = effectiveSelectedPlugins, - preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { - currentStreamBingeGroup - } else { - null - }, - preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, - ) - } else null + val innerJob = launch { + // Collect streams as they arrive + PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> + if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest - if (selected != null || !state.isAnyLoading || elapsed >= timeoutMs) { - nextEpisodeAutoPlaySearching = false - if (selected != null) { - nextEpisodeAutoPlaySourceName = selected.addonName - // Countdown before playing - for (i in 3 downTo 1) { - nextEpisodeAutoPlayCountdown = i - delay(1000) + val allStreams = state.groups.flatMap { it.streams } + + if (autoSelectTriggered) { + // Already resolved + } else if (timeoutElapsed) { + // Timeout elapsed: full select (binge group + fallback to mode) + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + autoSelectTriggered = true + selectedStream = candidate + } + } + } else { + // Before timeout: eagerly check binge group only + if (allStreams.isNotEmpty()) { + val earlyMatch = tryBingeGroupOnly(allStreams) + if (earlyMatch != null) { + autoSelectTriggered = true + selectedStream = earlyMatch + } } - switchToEpisodeStream(selected, nextVideo) - showNextEpisodeCard = false - nextEpisodeAutoPlayCountdown = null - nextEpisodeAutoPlaySourceName = null - } else if (!state.isAnyLoading || elapsed >= timeoutMs) { - // No stream found — open the episode streams panel for manual selection - episodeStreamsPanelState = EpisodeStreamsPanelState( - showStreams = true, - selectedEpisode = nextVideo, - ) - showEpisodesPanel = true - showNextEpisodeCard = false } - return@collectLatest + + // If all addons finished loading and no match yet, do a final full select + if (!autoSelectTriggered && !state.isAnyLoading) { + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + autoSelectTriggered = true + selectedStream = candidate + } + } + if (!autoSelectTriggered) { + autoSelectTriggered = true + } + return@collectLatest + } + + if (autoSelectTriggered) return@collectLatest } } + + // Timeout logic + val timeoutMs = timeoutSeconds * 1_000L + val isBoundedTimeout = timeoutSeconds in 1..30 + + if (isBoundedTimeout) { + // Bounded timeout (1-30s): wait, then trigger full select + delay(timeoutMs) + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + autoSelectTriggered = true + selectedStream = candidate + } + } + } + if (selectedStream != null) { + innerJob.cancel() + } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) { + // Streams arrived but no match after full select — don't wait further + innerJob.cancel() + autoSelectTriggered = true + } else { + // No addon responded yet — wait with hard ceiling + val completed = withTimeoutOrNull(timeoutMs) { innerJob.join() } + if (completed == null) { + innerJob.cancel() + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + autoSelectTriggered = true + } + } + } + } else { + // Instant (0) or unlimited: timeoutElapsed immediately so each + // addon response triggers a full select attempt in the collect. + timeoutElapsed = true + val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS + val completed = withTimeoutOrNull(hardTimeout) { innerJob.join() } + if (completed == null) { + innerJob.cancel() + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + autoSelectTriggered = true + } + } + } + + // Handle result + nextEpisodeAutoPlaySearching = false + if (selectedStream != null) { + nextEpisodeAutoPlaySourceName = selectedStream!!.addonName + // Countdown before playing + for (i in 3 downTo 1) { + nextEpisodeAutoPlayCountdown = i + delay(1000) + } + switchToEpisodeStream(selectedStream!!, nextVideo) + showNextEpisodeCard = false + nextEpisodeAutoPlayCountdown = null + nextEpisodeAutoPlaySourceName = null + } else { + // No stream found — open the episode streams panel for manual selection + episodeStreamsPanelState = EpisodeStreamsPanelState( + showStreams = true, + selectedEpisode = nextVideo, + ) + showEpisodesPanel = true + showNextEpisodeCard = false + } } } @@ -1805,6 +1947,14 @@ fun PlayerScreen( refreshTracks() showAudioModal = true }, + onVideoSettingsClick = if (isIos) { + { + showVideoSettingsModal = true + controlsVisible = true + } + } else { + null + }, onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null, @@ -1879,7 +2029,7 @@ fun PlayerScreen( // Skip intro/recap/outro button if (!playerControlsLocked) { SkipIntroButton( - interval = activeSkipInterval, + interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval, dismissed = skipIntervalDismissed, controlsVisible = controlsVisible, onSkip = { @@ -1973,6 +2123,15 @@ fun PlayerScreen( onDismiss = { showSubtitleModal = false }, ) + IosVideoSettingsModal( + visible = showVideoSettingsModal, + settings = playerSettingsUiState, + onSettingsChanged = { + playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value) + }, + onDismiss = { showVideoSettingsModal = false }, + ) + // Sources Panel PlayerSourcesPanel( visible = showSourcesPanel, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index 15f4f4d7..3ccbfea8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -7,6 +7,29 @@ import com.nuvio.app.features.streams.StreamAutoPlaySource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlin.math.abs + +val STREAM_AUTO_PLAY_TIMEOUT_VALUES: List = listOf( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30, Int.MAX_VALUE +) + +/** + * Snaps [value] to the nearest allowed timeout value in [STREAM_AUTO_PLAY_TIMEOUT_VALUES]. + * Ties break to the lower value. Negative values snap to 0. + */ +fun snapToAllowedTimeout(value: Int): Int { + if (value <= 0) return 0 + var bestValue = STREAM_AUTO_PLAY_TIMEOUT_VALUES[0] + var bestDistance = Long.MAX_VALUE + for (allowed in STREAM_AUTO_PLAY_TIMEOUT_VALUES) { + val distance = abs(value.toLong() - allowed.toLong()) + if (distance < bestDistance || (distance == bestDistance && allowed < bestValue)) { + bestDistance = distance + bestValue = allowed + } + } + return bestValue +} data class PlayerSettingsUiState( val showLoadingOverlay: Boolean = true, @@ -38,11 +61,26 @@ data class PlayerSettingsUiState( val introSubmitEnabled: Boolean = false, val streamAutoPlayNextEpisodeEnabled: Boolean = false, val streamAutoPlayPreferBingeGroup: Boolean = true, + val streamAutoPlayReuseBingeGroup: Boolean = true, val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE, val nextEpisodeThresholdPercent: Float = 99f, val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f, val useLibass: Boolean = false, val libassRenderType: String = "CUES", + val iosVideoOutputPreset: IosVideoOutputPreset = IosVideoOutputPreset.NativeEdr, + val iosToneMappingMode: IosToneMappingMode = IosToneMappingMode.Auto, + val iosTargetPrimaries: IosTargetPrimaries = IosTargetPrimaries.Auto, + val iosTargetTransfer: IosTargetTransfer = IosTargetTransfer.Auto, + val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.Auto, + val iosExtendedDynamicRangeEnabled: Boolean = true, + val iosTargetColorspaceHintEnabled: Boolean = true, + val iosHdrComputePeakEnabled: Boolean = true, + val iosDebandEnabled: Boolean = false, + val iosInterpolationEnabled: Boolean = false, + val iosBrightness: Int = 0, + val iosContrast: Int = 0, + val iosSaturation: Int = 0, + val iosGamma: Int = 0, ) object PlayerSettingsRepository { @@ -79,11 +117,26 @@ object PlayerSettingsRepository { private var introSubmitEnabled = false private var streamAutoPlayNextEpisodeEnabled = false private var streamAutoPlayPreferBingeGroup = true + private var streamAutoPlayReuseBingeGroup = true private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE private var nextEpisodeThresholdPercent = 99f private var nextEpisodeThresholdMinutesBeforeEnd = 2f private var useLibass = false private var libassRenderType = "CUES" + private var iosVideoOutputPreset = IosVideoOutputPreset.NativeEdr + private var iosToneMappingMode = IosToneMappingMode.Auto + private var iosTargetPrimaries = IosTargetPrimaries.Auto + private var iosTargetTransfer = IosTargetTransfer.Auto + private var iosHardwareDecoderMode = IosHardwareDecoderMode.Auto + private var iosExtendedDynamicRangeEnabled = true + private var iosTargetColorspaceHintEnabled = true + private var iosHdrComputePeakEnabled = true + private var iosDebandEnabled = false + private var iosInterpolationEnabled = false + private var iosBrightness = 0 + private var iosContrast = 0 + private var iosSaturation = 0 + private var iosGamma = 0 fun ensureLoaded() { if (hasLoaded) return @@ -125,11 +178,26 @@ object PlayerSettingsRepository { introSubmitEnabled = false streamAutoPlayNextEpisodeEnabled = false streamAutoPlayPreferBingeGroup = true + streamAutoPlayReuseBingeGroup = true nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE nextEpisodeThresholdPercent = 99f nextEpisodeThresholdMinutesBeforeEnd = 2f useLibass = false libassRenderType = "CUES" + iosVideoOutputPreset = IosVideoOutputPreset.NativeEdr + iosToneMappingMode = IosToneMappingMode.Auto + iosTargetPrimaries = IosTargetPrimaries.Auto + iosTargetTransfer = IosTargetTransfer.Auto + iosHardwareDecoderMode = IosHardwareDecoderMode.Auto + iosExtendedDynamicRangeEnabled = true + iosTargetColorspaceHintEnabled = true + iosHdrComputePeakEnabled = true + iosDebandEnabled = false + iosInterpolationEnabled = false + iosBrightness = 0 + iosContrast = 0 + iosSaturation = 0 + iosGamma = 0 publish() } @@ -190,6 +258,14 @@ object PlayerSettingsRepository { } streamAutoPlayRegex = PlayerSettingsStorage.loadStreamAutoPlayRegex() ?: "" streamAutoPlayTimeoutSeconds = PlayerSettingsStorage.loadStreamAutoPlayTimeoutSeconds() ?: 3 + // Legacy migration: 11 was the old sentinel for "unlimited" + if (streamAutoPlayTimeoutSeconds == 11) { + streamAutoPlayTimeoutSeconds = Int.MAX_VALUE + PlayerSettingsStorage.saveStreamAutoPlayTimeoutSeconds(streamAutoPlayTimeoutSeconds) + } else if (streamAutoPlayTimeoutSeconds !in STREAM_AUTO_PLAY_TIMEOUT_VALUES) { + streamAutoPlayTimeoutSeconds = snapToAllowedTimeout(streamAutoPlayTimeoutSeconds) + PlayerSettingsStorage.saveStreamAutoPlayTimeoutSeconds(streamAutoPlayTimeoutSeconds) + } skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: "" @@ -197,6 +273,7 @@ object PlayerSettingsRepository { introSubmitEnabled = PlayerSettingsStorage.loadIntroSubmitEnabled() ?: false streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true + streamAutoPlayReuseBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayReuseBingeGroup() ?: true nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode() ?.let { runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrNull() } ?: NextEpisodeThresholdMode.PERCENTAGE @@ -204,6 +281,30 @@ object PlayerSettingsRepository { nextEpisodeThresholdMinutesBeforeEnd = PlayerSettingsStorage.loadNextEpisodeThresholdMinutesBeforeEnd() ?: 2f useLibass = PlayerSettingsStorage.loadUseLibass() ?: false libassRenderType = PlayerSettingsStorage.loadLibassRenderType() ?: "CUES" + iosVideoOutputPreset = PlayerSettingsStorage.loadIosVideoOutputPreset() + ?.let { runCatching { IosVideoOutputPreset.valueOf(it) }.getOrNull() } + ?: IosVideoOutputPreset.NativeEdr + iosToneMappingMode = PlayerSettingsStorage.loadIosToneMappingMode() + ?.let { runCatching { IosToneMappingMode.valueOf(it) }.getOrNull() } + ?: IosToneMappingMode.Auto + iosTargetPrimaries = PlayerSettingsStorage.loadIosTargetPrimaries() + ?.let { runCatching { IosTargetPrimaries.valueOf(it) }.getOrNull() } + ?: IosTargetPrimaries.Auto + iosTargetTransfer = PlayerSettingsStorage.loadIosTargetTransfer() + ?.let { runCatching { IosTargetTransfer.valueOf(it) }.getOrNull() } + ?: IosTargetTransfer.Auto + iosHardwareDecoderMode = PlayerSettingsStorage.loadIosHardwareDecoderMode() + ?.let { runCatching { IosHardwareDecoderMode.valueOf(it) }.getOrNull() } + ?: IosHardwareDecoderMode.Auto + iosExtendedDynamicRangeEnabled = PlayerSettingsStorage.loadIosExtendedDynamicRangeEnabled() ?: true + iosTargetColorspaceHintEnabled = PlayerSettingsStorage.loadIosTargetColorspaceHintEnabled() ?: true + iosHdrComputePeakEnabled = PlayerSettingsStorage.loadIosHdrComputePeakEnabled() ?: true + iosDebandEnabled = PlayerSettingsStorage.loadIosDebandEnabled() ?: false + iosInterpolationEnabled = PlayerSettingsStorage.loadIosInterpolationEnabled() ?: false + iosBrightness = PlayerSettingsStorage.loadIosBrightness() ?: 0 + iosContrast = PlayerSettingsStorage.loadIosContrast() ?: 0 + iosSaturation = PlayerSettingsStorage.loadIosSaturation() ?: 0 + iosGamma = PlayerSettingsStorage.loadIosGamma() ?: 0 publish() } @@ -458,6 +559,14 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveStreamAutoPlayPreferBingeGroup(enabled) } + fun setStreamAutoPlayReuseBingeGroup(enabled: Boolean) { + ensureLoaded() + if (streamAutoPlayReuseBingeGroup == enabled) return + streamAutoPlayReuseBingeGroup = enabled + publish() + PlayerSettingsStorage.saveStreamAutoPlayReuseBingeGroup(enabled) + } + fun setNextEpisodeThresholdMode(mode: NextEpisodeThresholdMode) { ensureLoaded() if (nextEpisodeThresholdMode == mode) return @@ -498,6 +607,164 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveLibassRenderType(renderType) } + fun setIosVideoOutputPreset(preset: IosVideoOutputPreset) { + ensureLoaded() + iosVideoOutputPreset = preset + when (preset) { + IosVideoOutputPreset.NativeEdr -> { + iosExtendedDynamicRangeEnabled = true + iosTargetColorspaceHintEnabled = true + iosHdrComputePeakEnabled = true + iosToneMappingMode = IosToneMappingMode.Auto + iosTargetPrimaries = IosTargetPrimaries.Auto + iosTargetTransfer = IosTargetTransfer.Auto + } + IosVideoOutputPreset.SdrToneMapped -> { + iosExtendedDynamicRangeEnabled = false + iosTargetColorspaceHintEnabled = false + iosHdrComputePeakEnabled = true + iosToneMappingMode = IosToneMappingMode.Bt2390 + iosTargetPrimaries = IosTargetPrimaries.Bt709 + iosTargetTransfer = IosTargetTransfer.Srgb + } + IosVideoOutputPreset.Compatibility -> { + iosExtendedDynamicRangeEnabled = false + iosTargetColorspaceHintEnabled = true + iosHdrComputePeakEnabled = false + iosToneMappingMode = IosToneMappingMode.Auto + iosTargetPrimaries = IosTargetPrimaries.Auto + iosTargetTransfer = IosTargetTransfer.Auto + } + IosVideoOutputPreset.Custom -> Unit + } + publish() + saveIosVideoOutputSettings() + } + + fun setIosToneMappingMode(mode: IosToneMappingMode) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosToneMappingMode = mode + publish() + saveIosVideoOutputSettings() + } + + fun setIosTargetPrimaries(primaries: IosTargetPrimaries) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosTargetPrimaries = primaries + publish() + saveIosVideoOutputSettings() + } + + fun setIosTargetTransfer(transfer: IosTargetTransfer) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosTargetTransfer = transfer + publish() + saveIosVideoOutputSettings() + } + + fun setIosHardwareDecoderMode(mode: IosHardwareDecoderMode) { + ensureLoaded() + iosHardwareDecoderMode = mode + publish() + PlayerSettingsStorage.saveIosHardwareDecoderMode(mode.name) + } + + fun setIosExtendedDynamicRangeEnabled(enabled: Boolean) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosExtendedDynamicRangeEnabled = enabled + publish() + saveIosVideoOutputSettings() + } + + fun setIosTargetColorspaceHintEnabled(enabled: Boolean) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosTargetColorspaceHintEnabled = enabled + publish() + saveIosVideoOutputSettings() + } + + fun setIosHdrComputePeakEnabled(enabled: Boolean) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosHdrComputePeakEnabled = enabled + publish() + saveIosVideoOutputSettings() + } + + fun setIosDebandEnabled(enabled: Boolean) { + ensureLoaded() + iosDebandEnabled = enabled + publish() + PlayerSettingsStorage.saveIosDebandEnabled(enabled) + } + + fun setIosInterpolationEnabled(enabled: Boolean) { + ensureLoaded() + iosInterpolationEnabled = enabled + publish() + PlayerSettingsStorage.saveIosInterpolationEnabled(enabled) + } + + fun setIosBrightness(value: Int) { + ensureLoaded() + iosBrightness = value.coerceIn(-50, 50) + publish() + PlayerSettingsStorage.saveIosBrightness(iosBrightness) + } + + fun setIosContrast(value: Int) { + ensureLoaded() + iosContrast = value.coerceIn(-50, 50) + publish() + PlayerSettingsStorage.saveIosContrast(iosContrast) + } + + fun setIosSaturation(value: Int) { + ensureLoaded() + iosSaturation = value.coerceIn(-50, 50) + publish() + PlayerSettingsStorage.saveIosSaturation(iosSaturation) + } + + fun setIosGamma(value: Int) { + ensureLoaded() + iosGamma = value.coerceIn(-50, 50) + publish() + PlayerSettingsStorage.saveIosGamma(iosGamma) + } + + fun resetIosVideoOutputTuning() { + ensureLoaded() + iosBrightness = 0 + iosContrast = 0 + iosSaturation = 0 + iosGamma = 0 + iosDebandEnabled = false + iosInterpolationEnabled = false + publish() + PlayerSettingsStorage.saveIosBrightness(0) + PlayerSettingsStorage.saveIosContrast(0) + PlayerSettingsStorage.saveIosSaturation(0) + PlayerSettingsStorage.saveIosGamma(0) + PlayerSettingsStorage.saveIosDebandEnabled(false) + PlayerSettingsStorage.saveIosInterpolationEnabled(false) + } + + private fun saveIosVideoOutputSettings() { + PlayerSettingsStorage.saveIosVideoOutputPreset(iosVideoOutputPreset.name) + PlayerSettingsStorage.saveIosToneMappingMode(iosToneMappingMode.name) + PlayerSettingsStorage.saveIosTargetPrimaries(iosTargetPrimaries.name) + PlayerSettingsStorage.saveIosTargetTransfer(iosTargetTransfer.name) + PlayerSettingsStorage.saveIosExtendedDynamicRangeEnabled(iosExtendedDynamicRangeEnabled) + PlayerSettingsStorage.saveIosTargetColorspaceHintEnabled(iosTargetColorspaceHintEnabled) + PlayerSettingsStorage.saveIosHdrComputePeakEnabled(iosHdrComputePeakEnabled) + } + private fun publish() { _uiState.value = PlayerSettingsUiState( showLoadingOverlay = showLoadingOverlay, @@ -529,11 +796,26 @@ object PlayerSettingsRepository { introSubmitEnabled = introSubmitEnabled, streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled, streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup, + streamAutoPlayReuseBingeGroup = streamAutoPlayReuseBingeGroup, nextEpisodeThresholdMode = nextEpisodeThresholdMode, nextEpisodeThresholdPercent = nextEpisodeThresholdPercent, nextEpisodeThresholdMinutesBeforeEnd = nextEpisodeThresholdMinutesBeforeEnd, useLibass = useLibass, libassRenderType = libassRenderType, + iosVideoOutputPreset = iosVideoOutputPreset, + iosToneMappingMode = iosToneMappingMode, + iosTargetPrimaries = iosTargetPrimaries, + iosTargetTransfer = iosTargetTransfer, + iosHardwareDecoderMode = iosHardwareDecoderMode, + iosExtendedDynamicRangeEnabled = iosExtendedDynamicRangeEnabled, + iosTargetColorspaceHintEnabled = iosTargetColorspaceHintEnabled, + iosHdrComputePeakEnabled = iosHdrComputePeakEnabled, + iosDebandEnabled = iosDebandEnabled, + iosInterpolationEnabled = iosInterpolationEnabled, + iosBrightness = iosBrightness, + iosContrast = iosContrast, + iosSaturation = iosSaturation, + iosGamma = iosGamma, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 5c3b3756..2b07020e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -68,6 +68,8 @@ internal expect object PlayerSettingsStorage { fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) fun loadStreamAutoPlayPreferBingeGroup(): Boolean? fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) + fun loadStreamAutoPlayReuseBingeGroup(): Boolean? + fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) fun loadNextEpisodeThresholdMode(): String? fun saveNextEpisodeThresholdMode(mode: String) fun loadNextEpisodeThresholdPercent(): Float? @@ -78,6 +80,34 @@ internal expect object PlayerSettingsStorage { fun saveUseLibass(enabled: Boolean) fun loadLibassRenderType(): String? fun saveLibassRenderType(renderType: String) + fun loadIosVideoOutputPreset(): String? + fun saveIosVideoOutputPreset(preset: String) + fun loadIosToneMappingMode(): String? + fun saveIosToneMappingMode(mode: String) + fun loadIosTargetPrimaries(): String? + fun saveIosTargetPrimaries(primaries: String) + fun loadIosTargetTransfer(): String? + fun saveIosTargetTransfer(transfer: String) + fun loadIosHardwareDecoderMode(): String? + fun saveIosHardwareDecoderMode(mode: String) + fun loadIosExtendedDynamicRangeEnabled(): Boolean? + fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) + fun loadIosTargetColorspaceHintEnabled(): Boolean? + fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) + fun loadIosHdrComputePeakEnabled(): Boolean? + fun saveIosHdrComputePeakEnabled(enabled: Boolean) + fun loadIosDebandEnabled(): Boolean? + fun saveIosDebandEnabled(enabled: Boolean) + fun loadIosInterpolationEnabled(): Boolean? + fun saveIosInterpolationEnabled(enabled: Boolean) + fun loadIosBrightness(): Int? + fun saveIosBrightness(value: Int) + fun loadIosContrast(): Int? + fun saveIosContrast(value: Int) + fun loadIosSaturation(): Int? + fun saveIosSaturation(value: Int) + fun loadIosGamma(): Int? + fun saveIosGamma(value: Int) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 6e9487ed..013460c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -13,6 +13,7 @@ import com.nuvio.app.features.plugins.pluginContentId import com.nuvio.app.features.plugins.PluginRuntimeResult import com.nuvio.app.features.plugins.PluginScraper import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamAutoPlaySelector import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamParser import com.nuvio.app.features.streams.StreamsUiState @@ -21,6 +22,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -199,7 +201,8 @@ object PlayerStreamsRepository { return } - val initialGroups = streamAddons.map { addon -> + val installedAddonOrder = streamAddons.map { it.addonName } + val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, @@ -220,7 +223,7 @@ object PlayerStreamsRepository { streams = emptyList(), isLoading = true, ) - } + }, installedAddonOrder) stateFlow.value = StreamsUiState( groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), @@ -299,11 +302,20 @@ object PlayerStreamsRepository { } val jobs = addonJobs + pluginJobs + debridJobs - var debridPreparationLaunched = false + val completions = Channel(capacity = Channel.BUFFERED) jobs.forEach { deferred -> - val result = deferred.await() + launch { + completions.send(deferred.await()) + } + } + var debridPreparationLaunched = false + repeat(jobs.size) { + val result = completions.receive() stateFlow.update { current -> - val updated = current.groups.map { g -> if (g.addonId == result.addonId) result else g } + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { g -> if (g.addonId == result.addonId) result else g }, + installedOrder = installedAddonOrder, + ) val anyLoading = updated.any { it.isLoading } current.copy( groups = updated, @@ -340,6 +352,7 @@ object PlayerStreamsRepository { } } } + completions.close() } setJob(job) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 5648e096..3f1901f3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -50,12 +50,12 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioNetworkOfflineCard -import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.core.ui.nuvioSafeBottomPadding +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.features.home.MetaPreview @@ -404,12 +404,7 @@ private fun DiscoverPosterTile( contentScale = ContentScale.Crop, ) } - NuvioAnimatedWatchedBadge( - isVisible = isWatched, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(6.dp), - ) + NuvioPosterWatchedOverlay(isWatched = isWatched) } if (!hideLabels) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index a618b8ed..30d59534 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -4,16 +4,23 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -25,6 +32,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -37,26 +45,46 @@ import com.nuvio.app.features.debrid.DebridCredentialValidator import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults +import com.nuvio.app.features.debrid.DebridStreamAudioChannel +import com.nuvio.app.features.debrid.DebridStreamAudioTag +import com.nuvio.app.features.debrid.DebridStreamEncode +import com.nuvio.app.features.debrid.DebridStreamLanguage +import com.nuvio.app.features.debrid.DebridStreamPreferences +import com.nuvio.app.features.debrid.DebridStreamQuality +import com.nuvio.app.features.debrid.DebridStreamResolution +import com.nuvio.app.features.debrid.DebridStreamSortCriterion +import com.nuvio.app.features.debrid.DebridStreamSortDirection +import com.nuvio.app.features.debrid.DebridStreamSortKey +import com.nuvio.app.features.debrid.DebridStreamVisualTag import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_clear import nuvio.composeapp.generated.resources.action_reset import nuvio.composeapp.generated.resources.action_save -import nuvio.composeapp.generated.resources.action_validate +import nuvio.composeapp.generated.resources.action_saving import nuvio.composeapp.generated.resources.settings_debrid_add_key_first -import nuvio.composeapp.generated.resources.settings_debrid_description_template -import nuvio.composeapp.generated.resources.settings_debrid_description_template_description +import nuvio.composeapp.generated.resources.settings_debrid_dialog_placeholder +import nuvio.composeapp.generated.resources.settings_debrid_dialog_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_dialog_title import nuvio.composeapp.generated.resources.settings_debrid_enable import nuvio.composeapp.generated.resources.settings_debrid_enable_description import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice +import nuvio.composeapp.generated.resources.settings_debrid_description_template +import nuvio.composeapp.generated.resources.settings_debrid_description_template_description +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_title import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback_description import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count -import nuvio.composeapp.generated.resources.settings_debrid_key_valid +import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count_warning import nuvio.composeapp.generated.resources.settings_debrid_key_invalid import nuvio.composeapp.generated.resources.settings_debrid_name_template import nuvio.composeapp.generated.resources.settings_debrid_name_template_description +import nuvio.composeapp.generated.resources.settings_debrid_not_set import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_section_formatting @@ -82,7 +110,7 @@ internal fun LazyListScope.debridSettingsContent( SettingsSwitchRow( title = stringResource(Res.string.settings_debrid_enable), description = stringResource(Res.string.settings_debrid_enable_description), - checked = settings.enabled, + checked = settings.enabled && settings.hasAnyApiKey, enabled = settings.hasAnyApiKey, isTablet = isTablet, onCheckedChange = DebridSettingsRepository::setEnabled, @@ -99,21 +127,35 @@ internal fun LazyListScope.debridSettingsContent( } item { + var showApiKeyDialog by rememberSaveable { mutableStateOf(false) } + SettingsSection( title = stringResource(Res.string.settings_debrid_section_providers), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - DebridApiKeyRow( + DebridPreferenceRow( isTablet = isTablet, - providerId = DebridProviders.TORBOX_ID, title = DebridProviders.Torbox.displayName, description = stringResource(Res.string.settings_debrid_provider_torbox_description), - value = settings.torboxApiKey, - onApiKeyCommitted = DebridSettingsRepository::setTorboxApiKey, + value = maskDebridApiKey(settings.torboxApiKey, stringResource(Res.string.settings_debrid_not_set)), + enabled = true, + onClick = { showApiKeyDialog = true }, ) } } + + if (showApiKeyDialog) { + DebridApiKeyDialog( + providerId = DebridProviders.TORBOX_ID, + title = stringResource(Res.string.settings_debrid_dialog_title), + subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle), + placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder), + currentValue = settings.torboxApiKey, + onSave = DebridSettingsRepository::setTorboxApiKey, + onDismiss = { showApiKeyDialog = false }, + ) + } } item { @@ -163,43 +205,156 @@ internal fun LazyListScope.debridSettingsContent( } item { + var activeStreamPicker by rememberSaveable { mutableStateOf(null) } + val preferences = settings.streamPreferences + val rows = debridRuleRows(preferences) + + SettingsSection( + title = "Filters & Sorting", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = "Max results", + description = "Limit how many Direct Debrid sources appear.", + value = streamMaxResultsLabel(preferences.maxResults), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Sort streams", + description = "Choose how Direct Debrid sources are ordered.", + value = sortProfileLabel(preferences.sortCriteria), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per resolution limit", + description = "Cap repeated 2160p, 1080p, 720p results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerResolution), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per quality limit", + description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerQuality), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Size range", + description = "Filter streams by file size.", + value = sizeRangeLabel(preferences), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE }, + ) + rows.forEach { row -> + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = row.title, + description = row.description, + value = row.value, + enabled = settings.enabled, + onClick = { activeStreamPicker = row.picker }, + ) + } + } + } + + activeStreamPicker?.let { picker -> + DebridStreamPreferenceDialog( + picker = picker, + preferences = preferences, + onPreferencesChanged = DebridSettingsRepository::setStreamPreferences, + onDismiss = { activeStreamPicker = null }, + ) + } + } + + item { + var activeTemplateField by rememberSaveable { mutableStateOf(null) } + SettingsSection( title = stringResource(Res.string.settings_debrid_section_formatting), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - DebridTemplateRow( + DebridPreferenceRow( isTablet = isTablet, title = stringResource(Res.string.settings_debrid_name_template), description = stringResource(Res.string.settings_debrid_name_template_description), - value = settings.streamNameTemplate, - singleLine = true, - onTemplateCommitted = DebridSettingsRepository::setStreamNameTemplate, + value = templatePreview(settings.streamNameTemplate), + enabled = settings.enabled, + onClick = { activeTemplateField = DebridTemplateField.NAME }, ) SettingsGroupDivider(isTablet = isTablet) - DebridTemplateRow( + DebridPreferenceRow( isTablet = isTablet, title = stringResource(Res.string.settings_debrid_description_template), description = stringResource(Res.string.settings_debrid_description_template_description), - value = settings.streamDescriptionTemplate, - singleLine = false, - onTemplateCommitted = DebridSettingsRepository::setStreamDescriptionTemplate, + value = templatePreview(settings.streamDescriptionTemplate), + enabled = settings.enabled, + onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION }, ) SettingsGroupDivider(isTablet = isTablet) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 12.dp), - ) { - TextButton(onClick = DebridSettingsRepository::resetStreamTemplates) { - Text(stringResource(Res.string.action_reset)) - } - } + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_formatter_reset_title), + description = stringResource(Res.string.settings_debrid_formatter_reset_subtitle), + value = stringResource(Res.string.action_reset), + enabled = settings.enabled, + onClick = DebridSettingsRepository::resetStreamTemplates, + ) } } + + when (activeTemplateField) { + DebridTemplateField.NAME -> DebridTemplateDialog( + title = stringResource(Res.string.settings_debrid_name_template), + description = stringResource(Res.string.settings_debrid_name_template_description), + currentValue = settings.streamNameTemplate, + defaultValue = DebridStreamFormatterDefaults.NAME_TEMPLATE, + onSave = DebridSettingsRepository::setStreamNameTemplate, + onDismiss = { activeTemplateField = null }, + ) + DebridTemplateField.DESCRIPTION -> DebridTemplateDialog( + title = stringResource(Res.string.settings_debrid_description_template), + description = stringResource(Res.string.settings_debrid_description_template_description), + currentValue = settings.streamDescriptionTemplate, + defaultValue = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + onSave = DebridSettingsRepository::setStreamDescriptionTemplate, + onDismiss = { activeTemplateField = null }, + ) + null -> Unit + } } } +private enum class DebridTemplateField { + NAME, + DESCRIPTION, +} + +private fun templatePreview(value: String): String { + val firstLine = value + .lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotBlank() } + ?: return "" + return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." +} + @Composable private fun prepareCountLabel(limit: Int): String = if (limit == 1) { @@ -280,157 +435,848 @@ private fun DebridPrepareCountDialog( } } } + + Text( + text = stringResource(Res.string.settings_debrid_prepare_stream_count_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } } @Composable -private fun DebridApiKeyRow( +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridTemplateDialog( + title: String, + description: String, + currentValue: String, + defaultValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 140.dp, max = 280.dp), + minLines = 5, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton(onClick = { draft = defaultValue }) { + Text( + text = stringResource(Res.string.action_reset), + maxLines = 1, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(Res.string.action_cancel), + maxLines = 1, + ) + } + Button( + onClick = { + onSave(draft) + onDismiss() + }, + ) { + Text( + text = stringResource(Res.string.action_save), + maxLines = 1, + ) + } + } + } + } + } +} + +@Composable +private fun DebridPreferenceRow( isTablet: Boolean, + title: String, + description: String, + value: String, + enabled: Boolean, + onClick: () -> Unit, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 16.dp else 14.dp + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun DebridStreamPreferenceDialog( + picker: DebridStreamPicker, + preferences: DebridStreamPreferences, + onPreferencesChanged: (DebridStreamPreferences) -> Unit, + onDismiss: () -> Unit, +) { + when (picker) { + DebridStreamPicker.MAX_RESULTS -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxResults, + options = listOf(0, 5, 10, 20, 50), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxResults = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_RESOLUTION -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerResolution, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerResolution = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_QUALITY -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerQuality, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerQuality = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SORT_MODE -> DebridSingleChoiceDialog( + title = "Sort streams", + selectedValue = sortProfileFor(preferences.sortCriteria), + options = listOf( + DebridSortProfile.DEFAULT, + DebridSortProfile.LARGEST, + DebridSortProfile.SMALLEST, + DebridSortProfile.AUDIO, + DebridSortProfile.LANGUAGE, + ), + label = { sortProfileLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sortCriteria = sortCriteriaForProfile(value))) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SIZE_RANGE -> DebridSingleChoiceDialog( + title = "Size range", + selectedValue = preferences.sizeMinGb to preferences.sizeMaxGb, + options = listOf(0 to 0, 0 to 5, 0 to 10, 5 to 20, 10 to 50, 20 to 100), + label = { sizeRangeLabel(it.first, it.second) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sizeMinGb = value.first, sizeMaxGb = value.second)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Preferred resolutions", + selectedValues = preferences.preferredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredResolutions = value.ifEmpty { DebridStreamResolution.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Required resolutions", + selectedValues = preferences.requiredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Excluded resolutions", + selectedValues = preferences.excludedResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Preferred qualities", + selectedValues = preferences.preferredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredQualities = value.ifEmpty { DebridStreamQuality.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Required qualities", + selectedValues = preferences.requiredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_QUALITIES -> DebridMultiChoiceDialog( + title = "Excluded qualities", + selectedValues = preferences.excludedQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Preferred visual tags", + selectedValues = preferences.preferredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredVisualTags = value.ifEmpty { DebridStreamVisualTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Required visual tags", + selectedValues = preferences.requiredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Excluded visual tags", + selectedValues = preferences.excludedVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Preferred audio tags", + selectedValues = preferences.preferredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioTags = value.ifEmpty { DebridStreamAudioTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Required audio tags", + selectedValues = preferences.requiredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Excluded audio tags", + selectedValues = preferences.excludedAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Preferred channels", + selectedValues = preferences.preferredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioChannels = value.ifEmpty { DebridStreamAudioChannel.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Required channels", + selectedValues = preferences.requiredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Excluded channels", + selectedValues = preferences.excludedAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_ENCODES -> DebridMultiChoiceDialog( + title = "Preferred encodes", + selectedValues = preferences.preferredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredEncodes = value.ifEmpty { DebridStreamEncode.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_ENCODES -> DebridMultiChoiceDialog( + title = "Required encodes", + selectedValues = preferences.requiredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_ENCODES -> DebridMultiChoiceDialog( + title = "Excluded encodes", + selectedValues = preferences.excludedEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Preferred languages", + selectedValues = preferences.preferredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Required languages", + selectedValues = preferences.requiredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Excluded languages", + selectedValues = preferences.excludedLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Required release groups", + selectedValues = preferences.requiredReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Excluded release groups", + selectedValues = preferences.excludedReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun DebridIntChoiceDialog( + title: String, + selectedValue: Int, + options: List, + label: @Composable (Int) -> String, + onSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + DebridSingleChoiceDialog( + title = title, + selectedValue = selectedValue, + options = options, + label = label, + onSelected = onSelected, + onDismiss = onDismiss, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridSingleChoiceDialog( + title: String, + selectedValue: T, + options: List, + label: @Composable (T) -> String, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(options) { option -> + DebridDialogOptionRow( + text = label(option), + selected = option == selectedValue, + onClick = { + onSelected(option) + onDismiss() + }, + ) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridMultiChoiceDialog( + title: String, + selectedValues: List, + values: List, + label: @Composable (T) -> String, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, +) { + var draft by remember(selectedValues) { mutableStateOf(selectedValues) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(values) { option -> + val selected = option in draft + DebridDialogOptionRow( + text = label(option), + selected = selected, + showCheckbox = true, + onClick = { + draft = if (selected) { + draft - option + } else { + draft + option + } + }, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { draft = emptyList() }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(draft) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridTextListDialog( + title: String, + selectedValues: List, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, +) { + var value by remember(selectedValues) { mutableStateOf(selectedValues.joinToString("\n")) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = "Enter one group per line.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + minLines = 4, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { value = "" }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(value.split('\n', ',').map { it.trim() }.filter { it.isNotBlank() }.distinct()) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } + } +} + +@Composable +private fun DebridDialogSurface( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + content() + Spacer(modifier = Modifier.height(2.dp)) + } + } +} + +@Composable +private fun DebridDialogOptionRow( + text: String, + selected: Boolean, + showCheckbox: Boolean = false, + onClick: () -> Unit, +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (showCheckbox) { + Checkbox( + checked = selected, + onCheckedChange = { onClick() }, + ) + } else { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } +} + +@Composable +private fun streamMaxResultsLabel(value: Int): String = + if (value <= 0) "All streams" else "$value streams" + +private fun sortProfileLabel(value: DebridSortProfile): String = + when (value) { + DebridSortProfile.DEFAULT -> "Default" + DebridSortProfile.LARGEST -> "Largest first" + DebridSortProfile.SMALLEST -> "Smallest first" + DebridSortProfile.AUDIO -> "Best audio first" + DebridSortProfile.LANGUAGE -> "Language first" + } + +private fun debridRuleRows(preferences: DebridStreamPreferences): List = + listOf( + DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, "Preferred resolutions", "Sort selected resolutions first, in default order.", selectionCountLabel(preferences.preferredResolutions)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, "Required resolutions", "Only show selected resolutions.", selectionCountLabel(preferences.requiredResolutions)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, "Excluded resolutions", "Hide selected resolutions.", selectionCountLabel(preferences.excludedResolutions)), + DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, "Preferred qualities", "Sort selected qualities first, in default order.", selectionCountLabel(preferences.preferredQualities)), + DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, "Required qualities", "Only show selected source qualities.", selectionCountLabel(preferences.requiredQualities)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, "Excluded qualities", "Hide selected source qualities.", selectionCountLabel(preferences.excludedQualities)), + DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, "Preferred visual tags", "Sort DV, HDR, 10bit, IMAX and similar tags.", selectionCountLabel(preferences.preferredVisualTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, "Required visual tags", "Require DV, HDR, 10bit, IMAX, SDR and similar tags.", selectionCountLabel(preferences.requiredVisualTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, "Excluded visual tags", "Hide DV, HDR, 10bit, 3D and similar tags.", selectionCountLabel(preferences.excludedVisualTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, "Preferred audio tags", "Sort Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.preferredAudioTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, "Required audio tags", "Require Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.requiredAudioTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, "Excluded audio tags", "Hide selected audio tags.", selectionCountLabel(preferences.excludedAudioTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, "Preferred channels", "Sort preferred channel layouts first.", selectionCountLabel(preferences.preferredAudioChannels)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, "Required channels", "Only show selected channel layouts.", selectionCountLabel(preferences.requiredAudioChannels)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, "Excluded channels", "Hide selected channel layouts.", selectionCountLabel(preferences.excludedAudioChannels)), + DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, "Preferred encodes", "Sort AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.preferredEncodes)), + DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, "Required encodes", "Require AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.requiredEncodes)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, "Excluded encodes", "Hide selected encodes.", selectionCountLabel(preferences.excludedEncodes)), + DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, "Preferred languages", "Sort preferred audio languages first.", selectionCountLabel(preferences.preferredLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, "Required languages", "Only show streams with selected languages.", selectionCountLabel(preferences.requiredLanguages)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, "Excluded languages", "Hide streams where every language is excluded.", selectionCountLabel(preferences.excludedLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, "Required release groups", "Only show selected release groups.", selectionCountLabel(preferences.requiredReleaseGroups)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, "Excluded release groups", "Hide selected release groups.", selectionCountLabel(preferences.excludedReleaseGroups)), + ) + +private fun selectionCountLabel(values: List<*>): String = + if (values.isEmpty()) "Any" else "${values.size} selected" + +private fun sizeRangeLabel(preferences: DebridStreamPreferences): String = + sizeRangeLabel(preferences.sizeMinGb, preferences.sizeMaxGb) + +private fun sizeRangeLabel(minGb: Int, maxGb: Int): String = + when { + minGb <= 0 && maxGb <= 0 -> "Any" + minGb <= 0 -> "Up to ${maxGb}GB" + maxGb <= 0 -> "${minGb}GB+" + else -> "${minGb}-${maxGb}GB" + } + +private fun sortProfileFor(criteria: List): DebridSortProfile { + val normalized = criteria.map { it.key to it.direction } + return when { + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.DESC) -> DebridSortProfile.LARGEST + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.ASC) -> DebridSortProfile.SMALLEST + normalized.take(2) == listOf( + DebridStreamSortKey.AUDIO_TAG to DebridStreamSortDirection.DESC, + DebridStreamSortKey.AUDIO_CHANNEL to DebridStreamSortDirection.DESC, + ) -> DebridSortProfile.AUDIO + normalized.firstOrNull() == DebridStreamSortKey.LANGUAGE to DebridStreamSortDirection.DESC -> DebridSortProfile.LANGUAGE + else -> DebridSortProfile.DEFAULT + } +} + +private fun sortProfileLabel(criteria: List): String = + sortProfileLabel(sortProfileFor(criteria)) + +private fun sortCriteriaForProfile(profile: DebridSortProfile): List = + when (profile) { + DebridSortProfile.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridSortProfile.LARGEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridSortProfile.SMALLEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + DebridSortProfile.AUDIO -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridSortProfile.LANGUAGE -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.LANGUAGE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } + +private data class DebridRuleRow( + val picker: DebridStreamPicker, + val title: String, + val description: String, + val value: String, +) + +private enum class DebridSortProfile { + DEFAULT, + LARGEST, + SMALLEST, + AUDIO, + LANGUAGE, +} + +private enum class DebridStreamPicker { + MAX_RESULTS, + MAX_PER_RESOLUTION, + MAX_PER_QUALITY, + SORT_MODE, + SIZE_RANGE, + PREFERRED_RESOLUTIONS, + REQUIRED_RESOLUTIONS, + EXCLUDED_RESOLUTIONS, + PREFERRED_QUALITIES, + REQUIRED_QUALITIES, + EXCLUDED_QUALITIES, + PREFERRED_VISUAL_TAGS, + REQUIRED_VISUAL_TAGS, + EXCLUDED_VISUAL_TAGS, + PREFERRED_AUDIO_TAGS, + REQUIRED_AUDIO_TAGS, + EXCLUDED_AUDIO_TAGS, + PREFERRED_AUDIO_CHANNELS, + REQUIRED_AUDIO_CHANNELS, + EXCLUDED_AUDIO_CHANNELS, + PREFERRED_ENCODES, + REQUIRED_ENCODES, + EXCLUDED_ENCODES, + PREFERRED_LANGUAGES, + REQUIRED_LANGUAGES, + EXCLUDED_LANGUAGES, + REQUIRED_RELEASE_GROUPS, + EXCLUDED_RELEASE_GROUPS, +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridApiKeyDialog( providerId: String, title: String, - description: String, - value: String, - onApiKeyCommitted: (String) -> Unit, + subtitle: String, + placeholder: String, + currentValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, ) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 16.dp else 14.dp val scope = rememberCoroutineScope() - var draft by rememberSaveable(value) { mutableStateOf(value) } + var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) } var isValidating by rememberSaveable(providerId) { mutableStateOf(false) } - var validationMessage by rememberSaveable(providerId, value) { mutableStateOf(null) } + var validationMessage by rememberSaveable(providerId, currentValue) { mutableStateOf(null) } val normalizedDraft = draft.trim() - val validMessage = stringResource(Res.string.settings_debrid_key_valid) val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid) + val saveAndDismiss: () -> Unit = { + scope.launch { + isValidating = true + validationMessage = null + val valid = normalizedDraft.isNotBlank() && runCatching { + DebridCredentialValidator.validateProvider(providerId, normalizedDraft) + }.getOrDefault(false) + if (valid) { + onSave(normalizedDraft) + isValidating = false + onDismiss() + } else { + validationMessage = invalidMessage + isValidating = false + } + } + } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, + text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - - SettingsSecretTextField( - value = draft, - onValueChange = { - draft = it - validationMessage = null - }, - modifier = Modifier.fillMaxWidth(), - label = "$title API key", - ) - - validationMessage?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + OutlinedTextField( + value = draft, + onValueChange = { + draft = it + validationMessage = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(placeholder) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - draft = normalizedDraft - onApiKeyCommitted(normalizedDraft) - }, - enabled = normalizedDraft != value && !isValidating, - ) { - Text(stringResource(Res.string.action_save)) + validationMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) } - TextButton( - onClick = { - scope.launch { - isValidating = true - val valid = runCatching { - DebridCredentialValidator.validateProvider(providerId, normalizedDraft) - }.getOrDefault(false) - validationMessage = if (valid) validMessage else invalidMessage - isValidating = false - } - }, - enabled = normalizedDraft.isNotBlank() && !isValidating, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), ) { - Text(stringResource(Res.string.action_validate)) + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + TextButton( + onClick = { + onSave("") + onDismiss() + }, + enabled = !isValidating, + ) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = saveAndDismiss, + enabled = normalizedDraft.isNotBlank() && !isValidating, + ) { + Text( + if (isValidating) { + stringResource(Res.string.action_saving) + } else { + stringResource(Res.string.action_save) + }, + ) + } } } } } -@Composable -private fun DebridTemplateRow( - isTablet: Boolean, - title: String, - description: String, - value: String, - singleLine: Boolean, - onTemplateCommitted: (String) -> Unit, -) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 16.dp else 14.dp - var draft by rememberSaveable(value) { mutableStateOf(value) } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = singleLine, - minLines = if (singleLine) 1 else 4, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) - - Row(modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { onTemplateCommitted(draft) }, - enabled = draft != value, - ) { - Text(stringResource(Res.string.action_save)) - } - } - } +private fun maskDebridApiKey(key: String, notSetLabel: String): String { + val trimmed = key.trim() + if (trimmed.isBlank()) return notSetLabel + return if (trimmed.length <= 4) "****" else "******${trimmed.takeLast(4)}" } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 042d592d..98b1a83b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -55,7 +55,11 @@ import com.nuvio.app.features.player.AudioLanguageOption import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.ExternalPlayerApp import com.nuvio.app.features.player.ExternalPlayerPlatform +import com.nuvio.app.features.player.IosHardwareDecoderMode +import com.nuvio.app.features.player.IosTargetPrimaries +import com.nuvio.app.features.player.IosTargetTransfer import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.player.STREAM_AUTO_PLAY_TIMEOUT_VALUES import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.formatPlaybackSpeedLabel import com.nuvio.app.features.player.languageLabelForCode @@ -175,6 +179,9 @@ private fun PlaybackSettingsSection( var showReuseCacheDurationDialog by remember { mutableStateOf(false) } var showDecoderPriorityDialog by remember { mutableStateOf(false) } var showHoldToSpeedValueDialog by remember { mutableStateOf(false) } + var showIosHardwareDecoderDialog by remember { mutableStateOf(false) } + var showIosTargetPrimariesDialog by remember { mutableStateOf(false) } + var showIosTargetTransferDialog by remember { mutableStateOf(false) } var showLibassRenderTypeDialog by remember { mutableStateOf(false) } var showAutoPlayModeDialog by remember { mutableStateOf(false) } var showAutoPlaySourceDialog by remember { mutableStateOf(false) } @@ -359,7 +366,7 @@ private fun PlaybackSettingsSection( val timeoutSec = autoPlayPlayerSettings.streamAutoPlayTimeoutSeconds val timeoutLabel = when (timeoutSec) { 0 -> stringResource(Res.string.settings_playback_timeout_instant) - 11 -> stringResource(Res.string.settings_playback_timeout_unlimited) + Int.MAX_VALUE -> stringResource(Res.string.settings_playback_timeout_unlimited) else -> stringResource(Res.string.settings_playback_timeout_seconds, timeoutSec) } Column( @@ -385,8 +392,11 @@ private fun PlaybackSettingsSection( } ValueBox(text = timeoutLabel, modifier = Modifier.wrapContentWidth()) } - var sliderValue by remember(timeoutSec) { mutableFloatStateOf(timeoutSec.toFloat()) } - var lastHapticStep by remember(timeoutSec) { mutableStateOf(timeoutSec.toFloat()) } + val timeoutIndex = STREAM_AUTO_PLAY_TIMEOUT_VALUES.indexOf(timeoutSec) + .coerceAtLeast(0) + val maxIndex = (STREAM_AUTO_PLAY_TIMEOUT_VALUES.size - 1).toFloat() + var sliderValue by remember(timeoutIndex) { mutableFloatStateOf(timeoutIndex.toFloat()) } + var lastHapticStep by remember(timeoutIndex) { mutableStateOf(timeoutIndex.toFloat()) } Slider( value = sliderValue, onValueChange = { @@ -399,10 +409,11 @@ private fun PlaybackSettingsSection( } }, onValueChangeFinished = { - PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(sliderValue.toInt()) + val index = sliderValue.toInt().coerceIn(0, STREAM_AUTO_PLAY_TIMEOUT_VALUES.size - 1) + PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(STREAM_AUTO_PLAY_TIMEOUT_VALUES[index]) }, - valueRange = 0f..11f, - steps = calculateSteps(0f, 11f, 1f), + valueRange = 0f..maxIndex, + steps = calculateSteps(0f, maxIndex, 1f), colors = SliderDefaults.colors( thumbColor = MaterialTheme.colorScheme.primary, activeTrackColor = MaterialTheme.colorScheme.primary, @@ -487,6 +498,52 @@ private fun PlaybackSettingsSection( } } + if (isIos) { + SettingsSection( + title = "iOS video output", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = "Hardware decoder", + description = autoPlayPlayerSettings.iosHardwareDecoderMode.label, + isTablet = isTablet, + onClick = { showIosHardwareDecoderDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = "Extended dynamic range", + description = "Default Metal output mode for new playback sessions.", + checked = autoPlayPlayerSettings.iosExtendedDynamicRangeEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setIosExtendedDynamicRangeEnabled, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = "Display color hint", + description = "Let mpv target the active display color space by default.", + checked = autoPlayPlayerSettings.iosTargetColorspaceHintEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setIosTargetColorspaceHintEnabled, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Target primaries", + description = autoPlayPlayerSettings.iosTargetPrimaries.label, + isTablet = isTablet, + onClick = { showIosTargetPrimariesDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Target transfer", + description = autoPlayPlayerSettings.iosTargetTransfer.label, + isTablet = isTablet, + onClick = { showIosTargetTransferDialog = true }, + ) + } + } + } + if (!isIos) { SettingsSection( title = stringResource(Res.string.settings_playback_section_subtitle_rendering), @@ -606,6 +663,16 @@ private fun PlaybackSettingsSection( isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayPreferBingeGroup, ) + if (autoPlayPlayerSettings.streamAutoPlayPreferBingeGroup) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_reuse_binge_group), + description = stringResource(Res.string.settings_playback_reuse_binge_group_description), + checked = autoPlayPlayerSettings.streamAutoPlayReuseBingeGroup, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayReuseBingeGroup, + ) + } SettingsGroupDivider(isTablet = isTablet) var showThresholdModeDialog by remember { mutableStateOf(false) } SettingsNavigationRow( @@ -854,6 +921,48 @@ private fun PlaybackSettingsSection( ) } + if (showIosHardwareDecoderDialog) { + IosEnumSelectionDialog( + title = "Hardware decoder", + options = IosHardwareDecoderMode.entries, + selected = autoPlayPlayerSettings.iosHardwareDecoderMode, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosHardwareDecoderMode(it) + showIosHardwareDecoderDialog = false + }, + onDismiss = { showIosHardwareDecoderDialog = false }, + ) + } + + if (showIosTargetPrimariesDialog) { + IosEnumSelectionDialog( + title = "Target primaries", + options = IosTargetPrimaries.entries, + selected = autoPlayPlayerSettings.iosTargetPrimaries, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosTargetPrimaries(it) + showIosTargetPrimariesDialog = false + }, + onDismiss = { showIosTargetPrimariesDialog = false }, + ) + } + + if (showIosTargetTransferDialog) { + IosEnumSelectionDialog( + title = "Target transfer", + options = IosTargetTransfer.entries, + selected = autoPlayPlayerSettings.iosTargetTransfer, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosTargetTransfer(it) + showIosTargetTransferDialog = false + }, + onDismiss = { showIosTargetTransferDialog = false }, + ) + } + if (showLibassRenderTypeDialog) { LibassRenderTypeDialog( selectedRenderType = libassRenderType, @@ -1318,6 +1427,94 @@ private fun DecoderPriorityDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun IosEnumSelectionDialog( + title: String, + options: List, + selected: T, + label: (T) -> String, + onSelect: (T) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { option -> + val isSelected = option == selected + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(option) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label(option), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun HoldToSpeedValueDialog( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt new file mode 100644 index 00000000..5ca8968c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.streams + +object BingeGroupCacheRepository { + + fun save(contentId: String, bingeGroup: String) { + BingeGroupCacheStorage.save(hashedKey(contentId), bingeGroup) + } + + fun get(contentId: String): String? { + return BingeGroupCacheStorage.load(hashedKey(contentId)) + } + + fun remove(contentId: String) { + BingeGroupCacheStorage.remove(hashedKey(contentId)) + } + + private fun hashedKey(contentId: String): String { + val hash = contentId.fold(0L) { acc, c -> acc * 31 + c.code }.toULong() + return "binge_group_$hash" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt new file mode 100644 index 00000000..eda9a4ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.streams + +internal expect object BingeGroupCacheStorage { + fun load(hashedKey: String): String? + fun save(hashedKey: String, value: String) + fun remove(hashedKey: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt index 445af267..0ad4aa10 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt @@ -5,6 +5,7 @@ import com.nuvio.app.features.player.PlayerSettingsUiState object StreamAutoPlayPolicy { fun isEffectivelyEnabled(settings: PlayerSettingsUiState): Boolean { if (settings.streamReuseLastLinkEnabled) return true + if (settings.streamAutoPlayReuseBingeGroup && settings.streamAutoPlayPreferBingeGroup) return true return when (settings.streamAutoPlayMode) { StreamAutoPlayMode.MANUAL -> false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 5fbfb6fc..a5e97de5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -2,6 +2,34 @@ package com.nuvio.app.features.streams object StreamAutoPlaySelector { + fun orderAddonStreams( + groups: List, + installedOrder: List, + ): List { + if (groups.isEmpty()) return groups + + val addonRankByName = HashMap(installedOrder.size) + installedOrder.forEachIndexed { index, addonName -> + if (addonName !in addonRankByName) { + addonRankByName[addonName] = index + } + } + + val (directDebridEntries, remainingEntries) = groups.partition { group -> + group.addonId.startsWith("debrid:") || + group.streams.any { stream -> stream.isDirectDebridStream } + } + if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries + + val (addonEntries, pluginEntries) = remainingEntries.partition { group -> + group.addonName in addonRankByName + } + val orderedAddons = addonEntries.sortedBy { group -> + addonRankByName.getValue(group.addonName) + } + return directDebridEntries + orderedAddons + pluginEntries + } + fun selectAutoPlayStream( streams: List, mode: StreamAutoPlayMode, @@ -12,6 +40,7 @@ object StreamAutoPlaySelector { selectedPlugins: Set, preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, + bingeGroupOnly: Boolean = false, ): StreamItem? { if (streams.isEmpty()) return null @@ -29,7 +58,7 @@ object StreamAutoPlaySelector { } } if (candidateStreams.isEmpty()) return null - if (mode == StreamAutoPlayMode.MANUAL) return null + if (mode == StreamAutoPlayMode.MANUAL && !bingeGroupOnly) return null val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { @@ -37,6 +66,12 @@ object StreamAutoPlaySelector { stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable() } if (bingeGroupMatch != null) return bingeGroupMatch + // When bingeGroupOnly = true, do NOT fall through to mode-based selection + if (bingeGroupOnly) return null + } else if (bingeGroupOnly) { + // bingeGroupOnly requested but no preferredBingeGroup or preferBingeGroupInSelection is false + // Fall through to mode-based selection (bingeGroupOnly has no effect without a binge group to match) + if (mode == StreamAutoPlayMode.MANUAL) return null } return when (mode) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 1f7d42e1..5441ce47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -48,10 +48,11 @@ object StreamsRepository { ): String = "$type::$videoId::$season::$episode::$manualSelection" - fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { + fun load(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = season, episode = episode, manualSelection = manualSelection, @@ -59,10 +60,11 @@ object StreamsRepository { ) } - fun reload(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { + fun reload(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = season, episode = episode, manualSelection = manualSelection, @@ -70,7 +72,7 @@ object StreamsRepository { ) } - private fun load(type: String, videoId: String, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) { + private fun load(type: String, videoId: String, parentMetaId: String?, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) { val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.uiState.value @@ -105,7 +107,21 @@ object StreamsRepository { val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL && !(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH && !StreamAutoPlayPolicy.isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex)) - val isDirectAutoPlayFlow = isAutoPlayEnabled + + // Look up persisted binge group when both settings are enabled + val persistedBingeGroup = if ( + playerSettings.streamAutoPlayPreferBingeGroup && + playerSettings.streamAutoPlayReuseBingeGroup + ) { + parentMetaId?.let { BingeGroupCacheRepository.get(it) } + } else null + + // Enable direct auto-play flow if normal auto-play is enabled, + // OR if we have a persisted binge group in MANUAL mode + val bingeGroupDirectFlow = !manualSelection && + persistedBingeGroup != null && + autoPlayMode == StreamAutoPlayMode.MANUAL + val isDirectAutoPlayFlow = isAutoPlayEnabled || bingeGroupDirectFlow if (isDirectAutoPlayFlow) { _uiState.value = StreamsUiState( @@ -184,7 +200,8 @@ object StreamsRepository { } // Initialise loading placeholders - val initialGroups = streamAddons.map { addon -> + val installedAddonOrder = streamAddons.map { it.addonName } + val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, @@ -205,7 +222,7 @@ object StreamsRepository { streams = emptyList(), isLoading = true, ) - } + }, installedAddonOrder) _uiState.value = StreamsUiState( requestToken = requestToken, groups = initialGroups, @@ -226,9 +243,7 @@ object StreamsRepository { pluginProviderGroups.sumOf { it.scrapers.size } + debridTargets.size - val installedAddonNames = installedAddons - .map { it.displayTitle } - .toSet() + val installedAddonNames = installedAddonOrder.toSet() var autoSelectTriggered = false var timeoutElapsed = false var debridPreparationLaunched = false @@ -238,16 +253,59 @@ object StreamsRepository { } } - val timeoutJob = if (isAutoPlayEnabled) { - val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L - if (timeoutMs > 0L && playerSettings.streamAutoPlayTimeoutSeconds < 11) { + val timeoutJob = if (isDirectAutoPlayFlow) { + val timeoutSeconds = playerSettings.streamAutoPlayTimeoutSeconds + val isUnlimitedTimeout = timeoutSeconds == Int.MAX_VALUE + // Timeout semantics: + // - 0 (instant): timeoutElapsed immediately, full select on each response + // - 1-30 (bounded): wait the configured delay, then full select + // - unlimited (Int.MAX_VALUE): timeoutElapsed immediately, full select on each response, + // with 60s hard fallback to stream picker + if (timeoutSeconds <= 0 || isUnlimitedTimeout) { + timeoutElapsed = true + // For unlimited: launch a hard 60s fallback to dismiss overlay + if (isUnlimitedTimeout) { + launch { + delay(60_000L) + if (!autoSelectTriggered) { + autoSelectTriggered = true + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, + ) + _uiState.update { it.copy(autoPlayStream = selected) } + } + if (_uiState.value.autoPlayStream == null) { + _uiState.update { + it.copy( + isDirectAutoPlayFlow = false, + showDirectAutoPlayOverlay = false, + ) + } + } + } + } + } else { + null + } + } else { + // Bounded timeout (1-30s) launch { - delay(timeoutMs) + delay(timeoutSeconds * 1_000L) timeoutElapsed = true if (!autoSelectTriggered) { val allStreams = _uiState.value.groups.flatMap { it.streams } if (allStreams.isNotEmpty()) { - autoSelectTriggered = true val selected = StreamAutoPlaySelector.selectAutoPlayStream( streams = allStreams, mode = autoPlayMode, @@ -256,9 +314,14 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, ) - _uiState.update { it.copy(autoPlayStream = selected) } - if (selected == null) { + if (selected != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = selected) } + } else { _uiState.update { it.copy( isDirectAutoPlayFlow = false, @@ -269,11 +332,6 @@ object StreamsRepository { } } } - } else if (timeoutMs <= 0L) { - timeoutElapsed = true - null - } else { - null } } else { null @@ -383,9 +441,12 @@ object StreamsRepository { is StreamLoadCompletion.Addon -> { val result = completion.group _uiState.update { current -> - val updated = current.groups.map { group -> - if (group.addonId == result.addonId) result else group - } + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { group -> + if (group.addonId == result.addonId) result else group + }, + installedOrder = installedAddonOrder, + ) val anyLoading = updated.any { it.isLoading } current.copy( groups = updated, @@ -403,28 +464,31 @@ object StreamsRepository { } _uiState.update { current -> - val updated = current.groups.map { group -> - if (group.addonId != completion.addonId) { - group - } else { - val mergedStreams = if (completion.streams.isEmpty()) { - group.streams + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { group -> + if (group.addonId != completion.addonId) { + group } else { - (group.streams + completion.streams).sortedForGroupedDisplay() + val mergedStreams = if (completion.streams.isEmpty()) { + group.streams + } else { + (group.streams + completion.streams).sortedForGroupedDisplay() + } + val stillLoading = remaining > 0 + val finalError = if (mergedStreams.isEmpty() && !stillLoading) { + pluginFirstErrorByAddonId[completion.addonId] + } else { + null + } + group.copy( + streams = mergedStreams, + isLoading = stillLoading, + error = finalError, + ) } - val stillLoading = remaining > 0 - val finalError = if (mergedStreams.isEmpty() && !stillLoading) { - pluginFirstErrorByAddonId[completion.addonId] - } else { - null - } - group.copy( - streams = mergedStreams, - isLoading = stillLoading, - error = finalError, - ) - } - } + }, + installedOrder = installedAddonOrder, + ) val anyLoading = updated.any { it.isLoading } current.copy( groups = updated, @@ -437,9 +501,12 @@ object StreamsRepository { is StreamLoadCompletion.Debrid -> { val result = completion.group _uiState.update { current -> - val updated = current.groups.map { group -> - if (group.addonId == result.addonId) result else group - } + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { group -> + if (group.addonId == result.addonId) result else group + }, + installedOrder = installedAddonOrder, + ) val anyLoading = updated.any { it.isLoading } current.copy( groups = updated, @@ -471,9 +538,54 @@ object StreamsRepository { } } } + + // Early match / timeout-elapsed auto-select on each addon response + if (isDirectAutoPlayFlow && !autoSelectTriggered) { + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + if (timeoutElapsed) { + // After timeout: full fallback (bingeGroupOnly = false) + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, + ) + if (selected != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = selected) } + } + } else if (persistedBingeGroup != null) { + // Before timeout: try binge-group-only early match + val earlyMatch = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = true, + bingeGroupOnly = true, + ) + if (earlyMatch != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = earlyMatch) } + } + } + } + } } - if (isAutoPlayEnabled && !autoSelectTriggered) { + // All addons finished — run final auto-select if not yet triggered + if (isDirectAutoPlayFlow && !autoSelectTriggered) { autoSelectTriggered = true val allStreams = _uiState.value.groups.flatMap { it.streams } val selected = StreamAutoPlaySelector.selectAutoPlayStream( @@ -484,6 +596,9 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, ) _uiState.update { it.copy(autoPlayStream = selected) } } @@ -504,6 +619,7 @@ object StreamsRepository { } fun consumeAutoPlay() { + activeRequestKey = null _uiState.update { it.copy( autoPlayStream = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index e7078fd9..ee5b52e0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -176,6 +176,7 @@ fun StreamsScreen( StreamsRepository.load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = seasonNumber, episode = episodeNumber, manualSelection = manualSelection, @@ -277,6 +278,7 @@ fun StreamsScreen( StreamsRepository.reload( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = seasonNumber, episode = episodeNumber, manualSelection = manualSelection, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index dc43c983..e5361ea2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -13,6 +13,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async @@ -22,7 +23,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -44,6 +47,7 @@ data class TraktProgressUiState( val entries: List = emptyList(), val isLoading: Boolean = false, val errorMessage: String? = null, + val hasLoadedRemoteProgress: Boolean = false, ) object TraktProgressRepository { @@ -56,6 +60,8 @@ object TraktProgressRepository { private var hasLoaded = false private var refreshRequestId: Long = 0L + private val refreshJobMutex = Mutex() + private var inFlightRefresh: Deferred? = null fun ensureLoaded() { if (hasLoaded) return @@ -82,6 +88,25 @@ object TraktProgressRepository { } suspend fun refreshNow() { + ensureLoaded() + val refresh = refreshJobMutex.withLock { + inFlightRefresh?.takeIf { it.isActive } ?: scope.async { + refreshNowInternal() + }.also { inFlightRefresh = it } + } + + try { + refresh.await() + } finally { + refreshJobMutex.withLock { + if (inFlightRefresh == refresh && refresh.isCompleted) { + inFlightRefresh = null + } + } + } + } + + private suspend fun refreshNowInternal() { ensureLoaded() val requestId = nextRefreshRequestId() val headers = TraktAuthRepository.authorizedHeaders() @@ -109,34 +134,47 @@ object TraktProgressRepository { _uiState.value = TraktProgressUiState( entries = playbackEntries, - isLoading = false, + isLoading = true, errorMessage = null, + hasLoadedRemoteProgress = false, ) if (playbackEntries.isNotEmpty()) { launchHydration(requestId = requestId, entries = playbackEntries) } - scope.launch { - val completedEntries = runCatching { - fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers) - }.onFailure { error -> - if (error is CancellationException) throw error - log.w { "Failed to fetch Trakt history snapshot: ${error.message}" } - }.getOrNull() ?: return@launch + val completedEntries = runCatching { + coroutineScope { + val history = async { fetchHistoryEntries(headers) } + val watchedShowSeeds = async { fetchWatchedShowSeedEntries(headers) } + history.await() + watchedShowSeeds.await() + } + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to fetch Trakt history snapshot: ${error.message}" } + }.getOrNull() - if (!isLatestRefreshRequest(requestId)) return@launch - - val merged = mergeNewestByVideoId(playbackEntries + completedEntries) + if (completedEntries == null) { _uiState.value = _uiState.value.copy( - entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, isLoading = false, errorMessage = null, + hasLoadedRemoteProgress = false, ) + return + } - if (merged.isNotEmpty()) { - launchHydration(requestId = requestId, entries = merged) - } + if (!isLatestRefreshRequest(requestId)) return + + val merged = mergeNewestByVideoId(playbackEntries + completedEntries) + _uiState.value = _uiState.value.copy( + entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, + isLoading = false, + errorMessage = null, + hasLoadedRemoteProgress = true, + ) + + if (merged.isNotEmpty()) { + launchHydration(requestId = requestId, entries = merged) } } @@ -314,7 +352,8 @@ object TraktProgressRepository { mapPlaybackEpisode(item = item, fallbackIndex = index) } - mergeNewestByVideoId(inProgressMovies + inProgressEpisodes) + val merged = mergeNewestByVideoId(inProgressMovies + inProgressEpisodes) + merged } private suspend fun fetchHistoryEntries(headers: Map): List = withContext(Dispatchers.Default) { @@ -347,7 +386,8 @@ object TraktProgressRepository { .mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) } .distinctBy { entry -> entry.videoId } - mergeNewestByVideoId(completedEpisodes + completedMovies) + val merged = mergeNewestByVideoId(completedEpisodes + completedMovies) + merged } private suspend fun fetchWatchedShowSeedEntries( @@ -360,7 +400,7 @@ object TraktProgressRepository { headers = headers, ) val watchedShows = json.decodeFromString>(payload) - watchedShows + val mapped = watchedShows .mapNotNull { item -> mapWatchedShowSeed( item = item, @@ -368,6 +408,7 @@ object TraktProgressRepository { ) } .sortedByDescending { entry -> entry.lastUpdatedEpochMs } + mapped } private fun mergeNewestByVideoId(entries: List): List { @@ -436,6 +477,8 @@ object TraktProgressRepository { private fun invalidateInFlightRefreshes() { refreshRequestId += 1L + inFlightRefresh?.cancel() + inFlightRefresh = null } private fun isLatestRefreshRequest(requestId: Long): Boolean = refreshRequestId == requestId diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt index d8449cce..3109c4fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt @@ -14,7 +14,11 @@ data class ProgressSyncRecord( ) interface ProgressSyncAdapter { - suspend fun pull(profileId: Int): List + suspend fun pull( + profileId: Int, + sinceLastWatched: Long? = null, + limit: Int? = null, + ): List suspend fun push( profileId: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index cb2dc940..6743de34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -17,11 +17,23 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { encodeDefaults = true } - override suspend fun pull(profileId: Int): List { - val params = buildJsonObject { put("p_profile_id", profileId) } + override suspend fun pull( + profileId: Int, + sinceLastWatched: Long?, + limit: Int?, + ): List { + val params = buildJsonObject { + put("p_profile_id", profileId) + if (sinceLastWatched != null) { + put("p_since_last_watched", sinceLastWatched) + } + if (limit != null) { + put("p_limit", limit) + } + } val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) val serverEntries = result.decodeList() - val records = serverEntries.map { entry -> + return serverEntries.map { entry -> ProgressSyncRecord( contentId = entry.contentId, contentType = entry.contentType, @@ -33,7 +45,6 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { lastWatched = entry.lastWatched, ) } - return records } override suspend fun push( @@ -50,6 +61,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { position = entry.lastPositionMs, duration = entry.durationMs, lastWatched = entry.lastUpdatedEpochMs, + progressKey = progressKeyForEntry(entry), ) } val params = buildJsonObject { @@ -76,6 +88,13 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { } SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) } + + private fun progressKeyForEntry(entry: WatchProgressEntry): String = + if (entry.seasonNumber != null && entry.episodeNumber != null) { + "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" + } else { + entry.parentMetaId + } } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 6152fae8..19d6c046 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -60,6 +60,7 @@ internal object ContinueWatchingEnrichmentCache { } private const val storageKey = "cw_enrichment_cache" + private var lastPayloadHash: Int? = null fun getNextUpSnapshot(): List = loadPayload()?.nextUp ?: emptyList() @@ -75,11 +76,17 @@ internal object ContinueWatchingEnrichmentCache { fun saveSnapshots( nextUp: List, inProgress: List, + force: Boolean = false, ) { + val payload = CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress) + val payloadHash = payload.hashCode() + if (!force && lastPayloadHash == payloadHash) return + val encoded = runCatching { - json.encodeToString(CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress)) + json.encodeToString(payload) }.getOrNull() ?: return ContinueWatchingEnrichmentStorage.savePayload(ProfileScopedKey.of(storageKey), encoded) + lastPayloadHash = payloadHash } private fun loadPayload(): CachedEnrichmentPayload? { @@ -87,6 +94,8 @@ internal object ContinueWatchingEnrichmentCache { ?: return null return runCatching { json.decodeFromString(raw) - }.getOrNull() + }.getOrNull()?.also { payload -> + lastPayloadHash = payload.hashCode() + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 0485986b..9fb84629 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -118,6 +118,7 @@ data class WatchProgressEntry( data class WatchProgressUiState( val entries: List = emptyList(), + val hasLoadedRemoteProgress: Boolean = false, ) { val byVideoId: Map get() = entries.associateBy { it.videoId } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index ebdb27d5..8f4569b3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.watchprogress import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot @@ -10,12 +12,15 @@ import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions +import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -23,6 +28,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +private const val NUVIO_SYNC_PERIODIC_INTERVAL_MS = 5L * 60L * 1000L + object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("WatchProgressRepository") @@ -34,6 +41,8 @@ object WatchProgressRepository { private var currentProfileId: Int = 1 private var entriesByVideoId: MutableMap = mutableMapOf() private var metadataResolutionJob: Job? = null + private var isPullingNuvioSyncFromServer = false + private var hasCompletedInitialNuvioSyncPull = false internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter init { @@ -45,7 +54,10 @@ object WatchProgressRepository { ) ) { runCatching { TraktProgressRepository.refreshNow() } - .onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } } + .onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } + } } publish() } @@ -59,7 +71,10 @@ object WatchProgressRepository { ) ) { runCatching { TraktProgressRepository.refreshNow() } - .onFailure { error -> log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } } + .onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } + } } publish() } @@ -72,6 +87,25 @@ object WatchProgressRepository { } } } + + syncScope.launch { + while (true) { + delay(NUVIO_SYNC_PERIODIC_INTERVAL_MS) + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + if (shouldUseTraktProgress()) continue + + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) continue + if (!hasCompletedInitialNuvioSyncPull || isPullingNuvioSyncFromServer) continue + + runCatching { pullFromServer(ProfileRepository.activeProfileId) } + .onFailure { error -> + if (error is CancellationException) throw error + log.w { "Periodic NuvioSync pull failed: ${error.message}" } + } + } + } } fun ensureLoaded() { @@ -128,65 +162,96 @@ object WatchProgressRepository { val useTraktProgress = shouldUseTraktProgress() - if (useTraktProgress) { - runCatching { TraktProgressRepository.refreshNow() } - .onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } } - publish() + if (!useTraktProgress && isPullingNuvioSyncFromServer) { return } + if (!useTraktProgress) { + isPullingNuvioSyncFromServer = true + } - runCatching { - val serverEntries = syncAdapter.pull(profileId = profileId) - - val oldLocal = entriesByVideoId.toMap() - val newMap = mutableMapOf() - - serverEntries.forEach { entry -> - val videoId = entry.videoId - val cached = oldLocal[videoId] - newMap[videoId] = WatchProgressEntry( - contentType = entry.contentType, - parentMetaId = entry.contentId, - parentMetaType = cached?.parentMetaType ?: entry.contentType, - videoId = videoId, - title = cached?.title?.takeIf { it.isNotBlank() } ?: entry.contentId, - logo = cached?.logo, - poster = cached?.poster, - background = cached?.background, - seasonNumber = entry.season, - episodeNumber = entry.episode, - episodeTitle = cached?.episodeTitle, - episodeThumbnail = cached?.episodeThumbnail, - lastPositionMs = entry.position, - durationMs = entry.duration, - lastUpdatedEpochMs = entry.lastWatched, - providerName = cached?.providerName, - providerAddonId = cached?.providerAddonId, - lastStreamTitle = cached?.lastStreamTitle, - lastStreamSubtitle = cached?.lastStreamSubtitle, - pauseDescription = cached?.pauseDescription, - lastSourceUrl = cached?.lastSourceUrl, - isCompleted = isWatchProgressComplete(entry.position, entry.duration, false), - ) + try { + if (useTraktProgress) { + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { e -> + if (e is CancellationException) throw e + log.e(e) { "Failed to pull Trakt progress" } + } + publish() + return } - entriesByVideoId = newMap - hasLoaded = true - publish() - persist() + runCatching { + val sinceLastWatched = entriesByVideoId.values + .maxOfOrNull { entry -> entry.lastUpdatedEpochMs } + ?.takeIf { hasCompletedInitialNuvioSyncPull } + val serverEntries = syncAdapter.pull( + profileId = profileId, + sinceLastWatched = sinceLastWatched, + ) + val isIncrementalPull = sinceLastWatched != null + val oldLocal = entriesByVideoId.toMap() + val newMap = if (isIncrementalPull) { + entriesByVideoId.toMutableMap() + } else { + mutableMapOf() + } - resolveRemoteMetadata() - }.onFailure { e -> - log.e(e) { "Failed to pull watch progress from server" } + serverEntries.forEach { entry -> + newMap[entry.videoId] = entry.toWatchProgressEntry(cached = oldLocal[entry.videoId]) + } + + entriesByVideoId = newMap + hasLoaded = true + hasCompletedInitialNuvioSyncPull = true + publish() + persist() + + resolveRemoteMetadata() + }.onFailure { e -> + if (e is CancellationException) throw e + log.e(e) { "Failed to pull watch progress from server" } + } + } finally { + if (!useTraktProgress) { + isPullingNuvioSyncFromServer = false + } } } + private fun ProgressSyncRecord.toWatchProgressEntry(cached: WatchProgressEntry?): WatchProgressEntry = + WatchProgressEntry( + contentType = contentType, + parentMetaId = contentId, + parentMetaType = cached?.parentMetaType ?: contentType, + videoId = videoId, + title = cached?.title?.takeIf { it.isNotBlank() } ?: contentId, + logo = cached?.logo, + poster = cached?.poster, + background = cached?.background, + seasonNumber = season, + episodeNumber = episode, + episodeTitle = cached?.episodeTitle, + episodeThumbnail = cached?.episodeThumbnail, + lastPositionMs = position, + durationMs = duration, + lastUpdatedEpochMs = lastWatched, + providerName = cached?.providerName, + providerAddonId = cached?.providerAddonId, + lastStreamTitle = cached?.lastStreamTitle, + lastStreamSubtitle = cached?.lastStreamSubtitle, + pauseDescription = cached?.pauseDescription, + lastSourceUrl = cached?.lastSourceUrl, + isCompleted = isWatchProgressComplete(position, duration, false), + ) + private fun resolveRemoteMetadata() { val needsResolution = entriesByVideoId.values .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } .groupBy { it.parentMetaId to it.contentType } - if (needsResolution.isEmpty()) return + if (needsResolution.isEmpty()) { + return + } metadataResolutionJob?.cancel() metadataResolutionJob = syncScope.launch { @@ -201,7 +266,10 @@ object WatchProgressRepository { val (metaId, metaType) = key val meta = runCatching { MetaDetailsRepository.fetch(metaType, metaId) - }.getOrNull() ?: continue + }.getOrNull() + if (meta == null) { + continue + } for (entry in entries) { val episodeVideo = if (entry.seasonNumber != null && entry.episodeNumber != null) { @@ -428,6 +496,11 @@ object WatchProgressRepository { val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } _uiState.value = WatchProgressUiState( entries = sortedEntries, + hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { + TraktProgressRepository.uiState.value.hasLoadedRemoteProgress + } else { + hasLoaded + }, ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt index 271f6f42..593fa6af 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt @@ -1,6 +1,9 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamClientResolveRaw +import com.nuvio.app.features.streams.StreamClientResolveStream import com.nuvio.app.features.streams.StreamItem import kotlin.test.Test import kotlin.test.assertEquals @@ -48,25 +51,160 @@ class DirectDebridStreamFilterTest { assertTrue(plainTorrent.isTorrentStream) } + @Test + fun `sorts and limits streams by quality and size`() { + val streams = listOf( + stream(resolution = "1080p", size = 20), + stream(resolution = "2160p", size = 10), + stream(resolution = "2160p", size = 30), + stream(resolution = "720p", size = 40), + ) + + val filtered = DirectDebridStreamFilter.filterInstant( + streams, + DebridSettings( + streamMaxResults = 2, + streamSortMode = DebridStreamSortMode.QUALITY_DESC, + ), + ) + + assertEquals(listOf(30L, 10L), filtered.map { it.clientResolve?.stream?.raw?.size }) + } + + @Test + fun `filters minimum quality dolby vision hdr and codec`() { + val hdrHevc = stream(resolution = "2160p", hdr = listOf("HDR10"), codec = "HEVC", size = 10) + val dvHevc = stream(resolution = "2160p", hdr = listOf("DV", "HDR10"), codec = "HEVC", size = 20) + val sdrAvc = stream(resolution = "1080p", codec = "AVC", size = 30) + val hdHevc = stream(resolution = "720p", codec = "HEVC", size = 40) + + val noDvHdrHevc4k = DirectDebridStreamFilter.filterInstant( + listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), + DebridSettings( + streamMinimumQuality = DebridStreamMinimumQuality.P2160, + streamDolbyVisionFilter = DebridStreamFeatureFilter.EXCLUDE, + streamHdrFilter = DebridStreamFeatureFilter.ONLY, + streamCodecFilter = DebridStreamCodecFilter.HEVC, + ), + ) + + assertEquals(listOf(10L), noDvHdrHevc4k.map { it.clientResolve?.stream?.raw?.size }) + + val dvOnly = DirectDebridStreamFilter.filterInstant( + listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), + DebridSettings(streamDolbyVisionFilter = DebridStreamFeatureFilter.ONLY), + ) + + assertEquals(listOf(20L), dvOnly.map { it.clientResolve?.stream?.raw?.size }) + } + + @Test + fun `applies stream preference filters and sort criteria`() { + val remuxAtmos = stream( + resolution = "2160p", + quality = "BluRay REMUX", + codec = "HEVC", + audio = listOf("Atmos", "TrueHD"), + channels = listOf("7.1"), + languages = listOf("en"), + group = "GOOD", + size = 40_000_000_000, + ) + val webAac = stream( + resolution = "2160p", + quality = "WEB-DL", + codec = "AVC", + audio = listOf("AAC"), + channels = listOf("2.0"), + languages = listOf("en"), + group = "NOPE", + size = 4_000_000_000, + ) + val blurayDts = stream( + resolution = "1080p", + quality = "BluRay", + codec = "AVC", + audio = listOf("DTS"), + channels = listOf("5.1"), + languages = listOf("hi"), + group = "GOOD", + size = 12_000_000_000, + ) + + val filtered = DirectDebridStreamFilter.filterInstant( + listOf(webAac, blurayDts, remuxAtmos), + DebridSettings( + streamPreferences = DebridStreamPreferences( + maxResults = 2, + maxPerResolution = 1, + sizeMinGb = 5, + requiredResolutions = listOf(DebridStreamResolution.P2160, DebridStreamResolution.P1080), + excludedQualities = listOf(DebridStreamQuality.WEB_DL), + requiredAudioChannels = listOf(DebridStreamAudioChannel.CH_7_1, DebridStreamAudioChannel.CH_5_1), + excludedEncodes = listOf(DebridStreamEncode.UNKNOWN), + excludedLanguages = listOf(DebridStreamLanguage.IT), + requiredReleaseGroups = listOf("GOOD"), + sortCriteria = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC), + ), + ), + ), + ) + + assertEquals(listOf(40_000_000_000L, 12_000_000_000L), filtered.map { it.clientResolve?.stream?.raw?.size }) + } + private fun stream( - service: String?, - cached: Boolean?, + service: String? = DebridProviders.TORBOX_ID, + cached: Boolean? = true, type: String = "debrid", infoHash: String = "hash", fileIdx: Int = 1, + resolution: String? = null, + quality: String? = null, + hdr: List = emptyList(), + codec: String? = null, + audio: List = emptyList(), + channels: List = emptyList(), + languages: List = emptyList(), + group: String? = null, + size: Long? = null, ): StreamItem = StreamItem( - name = "Stream", + name = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", + description = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", addonName = "Direct Debrid", addonId = "debrid", clientResolve = StreamClientResolve( type = type, service = service, isCached = cached, - infoHash = infoHash, + infoHash = infoHash + size.orEmptyHashPart() + resolution.orEmpty() + quality.orEmpty() + codec.orEmpty(), fileIdx = fileIdx, - filename = "video.mkv", + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + stream = StreamClientResolveStream( + raw = StreamClientResolveRaw( + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + size = size, + folderSize = size, + parsed = StreamClientResolveParsed( + resolution = resolution, + quality = quality, + hdr = hdr, + codec = codec, + audio = audio, + channels = channels, + languages = languages, + group = group, + ), + ), + ), ), ) } +private fun Long?.orEmptyHashPart(): String = + this?.toString().orEmpty() diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index 0ac46039..dc85c449 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -17,6 +17,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( @@ -24,6 +31,13 @@ actual object DebridSettingsStorage { torboxApiKeyKey, realDebridApiKeyKey, instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, streamNameTemplateKey, streamDescriptionTemplateKey, ) @@ -52,6 +66,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) { @@ -104,6 +160,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)) } } @@ -117,6 +180,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/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 4c627dc2..9012a96c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -15,6 +15,21 @@ interface NuvioPlayerBridge { fun seekTo(positionMs: Long) fun seekBy(offsetMs: Long) fun retry() + fun configureVideoOutput( + hardwareDecoder: String, + targetColorspaceHint: Boolean, + toneMapping: String, + hdrComputePeak: Boolean, + targetPrimaries: String, + targetTransfer: String, + extendedDynamicRange: Boolean, + deband: Boolean, + interpolation: Boolean, + brightness: Int, + contrast: Int, + saturation: Int, + gamma: Int, + ) fun setPlaybackSpeed(speed: Float) fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom fun getAudioTrackCount(): Int diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 2877b04c..733bf162 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -3,11 +3,13 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitViewController +import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.delay @@ -37,6 +39,9 @@ actual fun PlatformPlayerSurface( val latestOnControllerReady = rememberUpdatedState(onControllerReady) val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnError = rememberUpdatedState(onError) + PlayerSettingsRepository.ensureLoaded() + val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() + val latestPlayerSettings = rememberUpdatedState(playerSettings) val bridge = remember { NuvioPlayerBridgeFactory.create() @@ -71,6 +76,10 @@ actual fun PlatformPlayerSurface( bridge.retry() } + override fun configureIosVideoOutput(settings: PlayerSettingsUiState) { + bridge.applyIosVideoOutputSettings(settings) + } + override fun setPlaybackSpeed(speed: Float) { bridge.setPlaybackSpeed(speed) } @@ -214,6 +223,7 @@ actual fun PlatformPlayerSurface( // Load file and set initial state LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) { + bridge.applyIosVideoOutputSettings(latestPlayerSettings.value) bridge.loadFileWithAudio( sourceUrl, sourceAudioUrl, @@ -242,6 +252,10 @@ actual fun PlatformPlayerSurface( ) } + LaunchedEffect(bridge, playerSettings) { + bridge.applyIosVideoOutputSettings(playerSettings) + } + // Polling for snapshots LaunchedEffect(bridge) { var lastReportedError: String? = null @@ -280,6 +294,24 @@ actual fun PlatformPlayerSurface( ) } +private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) { + configureVideoOutput( + hardwareDecoder = settings.iosHardwareDecoderMode.mpvValue, + targetColorspaceHint = settings.iosTargetColorspaceHintEnabled, + toneMapping = settings.iosToneMappingMode.mpvValue, + hdrComputePeak = settings.iosHdrComputePeakEnabled, + targetPrimaries = settings.iosTargetPrimaries.mpvValue, + targetTransfer = settings.iosTargetTransfer.mpvValue, + extendedDynamicRange = settings.iosExtendedDynamicRangeEnabled, + deband = settings.iosDebandEnabled, + interpolation = settings.iosInterpolationEnabled, + brightness = settings.iosBrightness, + contrast = settings.iosContrast, + saturation = settings.iosSaturation, + gamma = settings.iosGamma, + ) +} + private fun Color.toMpvColorString(): String { val redInt = (red * 255f).toInt().coerceIn(0, 255) val greenInt = (green * 255f).toInt().coerceIn(0, 255) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index 0aedbb30..48649658 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -49,11 +49,26 @@ actual object PlayerSettingsStorage { private const val introSubmitEnabledKey = "intro_submit_enabled" private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" + private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_binge_group" private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2" private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" private const val useLibassKey = "use_libass" private const val libassRenderTypeKey = "libass_render_type" + private const val iosVideoOutputPresetKey = "ios_video_output_preset" + private const val iosToneMappingModeKey = "ios_tone_mapping_mode" + private const val iosTargetPrimariesKey = "ios_target_primaries" + private const val iosTargetTransferKey = "ios_target_transfer" + private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" + private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" + private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" + private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" + private const val iosDebandEnabledKey = "ios_deband_enabled" + private const val iosInterpolationEnabledKey = "ios_interpolation_enabled" + private const val iosBrightnessKey = "ios_brightness" + private const val iosContrastKey = "ios_contrast" + private const val iosSaturationKey = "ios_saturation" + private const val iosGammaKey = "ios_gamma" private val syncKeys = listOf( showLoadingOverlayKey, resizeModeKey, @@ -85,13 +100,48 @@ actual object PlayerSettingsStorage { animeSkipClientIdKey, streamAutoPlayNextEpisodeEnabledKey, streamAutoPlayPreferBingeGroupKey, + streamAutoPlayReuseBingeGroupKey, nextEpisodeThresholdModeKey, nextEpisodeThresholdPercentKey, nextEpisodeThresholdMinutesBeforeEndKey, useLibassKey, libassRenderTypeKey, + iosVideoOutputPresetKey, + iosToneMappingModeKey, + iosTargetPrimariesKey, + iosTargetTransferKey, + iosHardwareDecoderModeKey, + iosExtendedDynamicRangeEnabledKey, + iosTargetColorspaceHintEnabledKey, + iosHdrComputePeakEnabledKey, + iosDebandEnabledKey, + iosInterpolationEnabledKey, + iosBrightnessKey, + iosContrastKey, + iosSaturationKey, + iosGammaKey, ) + private fun loadBoolean(keyBase: String): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(keyBase) + return if (defaults.objectForKey(key) != null) defaults.boolForKey(key) else null + } + + private fun saveBoolean(keyBase: String, enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(keyBase)) + } + + private fun loadInt(keyBase: String): Int? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(keyBase) + return if (defaults.objectForKey(key) != null) defaults.integerForKey(key).toInt() else null + } + + private fun saveInt(keyBase: String, value: Int) { + NSUserDefaults.standardUserDefaults.setInteger(value.toLong(), forKey = ProfileScopedKey.of(keyBase)) + } + actual fun loadShowLoadingOverlay(): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(showLoadingOverlayKey) @@ -506,6 +556,20 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(streamAutoPlayPreferBingeGroupKey)) } + actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey)) + } + actual fun loadNextEpisodeThresholdMode(): String? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(nextEpisodeThresholdModeKey) @@ -552,6 +616,100 @@ actual object PlayerSettingsStorage { actual fun saveLibassRenderType(renderType: String) {} + actual fun loadIosVideoOutputPreset(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosVideoOutputPresetKey)) + + actual fun saveIosVideoOutputPreset(preset: String) { + NSUserDefaults.standardUserDefaults.setObject(preset, forKey = ProfileScopedKey.of(iosVideoOutputPresetKey)) + } + + actual fun loadIosToneMappingMode(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosToneMappingModeKey)) + + actual fun saveIosToneMappingMode(mode: String) { + NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosToneMappingModeKey)) + } + + actual fun loadIosTargetPrimaries(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosTargetPrimariesKey)) + + actual fun saveIosTargetPrimaries(primaries: String) { + NSUserDefaults.standardUserDefaults.setObject(primaries, forKey = ProfileScopedKey.of(iosTargetPrimariesKey)) + } + + actual fun loadIosTargetTransfer(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosTargetTransferKey)) + + actual fun saveIosTargetTransfer(transfer: String) { + NSUserDefaults.standardUserDefaults.setObject(transfer, forKey = ProfileScopedKey.of(iosTargetTransferKey)) + } + + actual fun loadIosHardwareDecoderMode(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosHardwareDecoderModeKey)) + + actual fun saveIosHardwareDecoderMode(mode: String) { + NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosHardwareDecoderModeKey)) + } + + actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? = + loadBoolean(iosExtendedDynamicRangeEnabledKey) + + actual fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) { + saveBoolean(iosExtendedDynamicRangeEnabledKey, enabled) + } + + actual fun loadIosTargetColorspaceHintEnabled(): Boolean? = + loadBoolean(iosTargetColorspaceHintEnabledKey) + + actual fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) { + saveBoolean(iosTargetColorspaceHintEnabledKey, enabled) + } + + actual fun loadIosHdrComputePeakEnabled(): Boolean? = + loadBoolean(iosHdrComputePeakEnabledKey) + + actual fun saveIosHdrComputePeakEnabled(enabled: Boolean) { + saveBoolean(iosHdrComputePeakEnabledKey, enabled) + } + + actual fun loadIosDebandEnabled(): Boolean? = + loadBoolean(iosDebandEnabledKey) + + actual fun saveIosDebandEnabled(enabled: Boolean) { + saveBoolean(iosDebandEnabledKey, enabled) + } + + actual fun loadIosInterpolationEnabled(): Boolean? = + loadBoolean(iosInterpolationEnabledKey) + + actual fun saveIosInterpolationEnabled(enabled: Boolean) { + saveBoolean(iosInterpolationEnabledKey, enabled) + } + + actual fun loadIosBrightness(): Int? = loadInt(iosBrightnessKey) + + actual fun saveIosBrightness(value: Int) { + saveInt(iosBrightnessKey, value) + } + + actual fun loadIosContrast(): Int? = loadInt(iosContrastKey) + + actual fun saveIosContrast(value: Int) { + saveInt(iosContrastKey, value) + } + + actual fun loadIosSaturation(): Int? = loadInt(iosSaturationKey) + + actual fun saveIosSaturation(value: Int) { + saveInt(iosSaturationKey, value) + } + + actual fun loadIosGamma(): Int? = loadInt(iosGammaKey) + + actual fun saveIosGamma(value: Int) { + saveInt(iosGammaKey, value) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } @@ -583,11 +741,26 @@ actual object PlayerSettingsStorage { loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) } loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } + loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) } + loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) } + loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } + loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } + loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } + loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } + loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } + loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } + loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) } + loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) } + loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) } + loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) } + loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) } + loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -626,10 +799,25 @@ actual object PlayerSettingsStorage { payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey) payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) + payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) + payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset) + payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode) + payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) + payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) + payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) + payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) + payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) + payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) + payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled) + payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled) + payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness) + payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast) + payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation) + payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma) } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt new file mode 100644 index 00000000..45e807a4 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +actual object BingeGroupCacheStorage { + actual fun load(hashedKey: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(hashedKey)) + + actual fun save(hashedKey: String, value: String) { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(hashedKey)) + } + + actual fun remove(hashedKey: String) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(hashedKey)) + } +} diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 11aa4529..10b019f6 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=62 -MARKETING_VERSION=0.1.0 +CURRENT_PROJECT_VERSION=64 +MARKETING_VERSION=0.1.22 diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 06779ac2..afcdc601 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -28,6 +28,37 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) } func retry() { playerVC?.retryPlayback() } + func configureVideoOutput( + hardwareDecoder: String, + targetColorspaceHint: Bool, + toneMapping: String, + hdrComputePeak: Bool, + targetPrimaries: String, + targetTransfer: String, + extendedDynamicRange: Bool, + deband: Bool, + interpolation: Bool, + brightness: Int32, + contrast: Int32, + saturation: Int32, + gamma: Int32 + ) { + playerVC?.configureVideoOutput( + hardwareDecoder: hardwareDecoder, + targetColorspaceHint: targetColorspaceHint, + toneMapping: toneMapping, + hdrComputePeak: hdrComputePeak, + targetPrimaries: targetPrimaries, + targetTransfer: targetTransfer, + extendedDynamicRange: extendedDynamicRange, + deband: deband, + interpolation: interpolation, + brightness: Int(brightness), + contrast: Int(contrast), + saturation: Int(saturation), + gamma: Int(gamma) + ) + } func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } @@ -204,6 +235,7 @@ final class MPVPlayerViewController: UIViewController { metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale metalLayer.framebufferOnly = true metalLayer.backgroundColor = UIColor.black.cgColor + metalLayer.wantsExtendedDynamicRangeContent = true view.layer.addSublayer(metalLayer) layoutMetalLayer() @@ -273,6 +305,8 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) checkError(mpv_set_option_string(mpv, "hwdec", "auto")) + checkError(mpv_set_option_string(mpv, "audio-channels", "stereo")) + checkError(mpv_set_option_string(mpv, "audio-fallback-to-null", "yes")) checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo")) checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1")) checkError(mpv_set_option_string(mpv, "vulkan-async-compute", "no")) @@ -284,7 +318,7 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_option_string(mpv, "keep-open", "yes")) checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) checkError(mpv_set_option_string(mpv, "tone-mapping", "auto")) - checkError(mpv_set_option_string(mpv, "hdr-compute-peak", "no")) + checkError(mpv_set_option_string(mpv, "hdr-compute-peak", "yes")) checkError(mpv_initialize(mpv)) @@ -312,12 +346,12 @@ final class MPVPlayerViewController: UIViewController { @objc private func enterBackground() { guard mpv != nil else { return } pausePlayback() - checkError(mpv_set_option_string(mpv, "vid", "no")) + setStringProperty("vid", "no") } @objc private func enterForeground() { guard mpv != nil else { return } - checkError(mpv_set_option_string(mpv, "vid", "auto")) + setStringProperty("vid", "auto") playPlayback() } @@ -433,6 +467,38 @@ final class MPVPlayerViewController: UIViewController { } } + func configureVideoOutput( + hardwareDecoder: String, + targetColorspaceHint: Bool, + toneMapping: String, + hdrComputePeak: Bool, + targetPrimaries: String, + targetTransfer: String, + extendedDynamicRange: Bool, + deband: Bool, + interpolation: Bool, + brightness: Int, + contrast: Int, + saturation: Int, + gamma: Int + ) { + metalLayer.wantsExtendedDynamicRangeContent = extendedDynamicRange + guard mpv != nil else { return } + + setStringProperty("hwdec", hardwareDecoder) + setStringProperty("target-colorspace-hint", targetColorspaceHint ? "yes" : "no") + setStringProperty("tone-mapping", toneMapping) + setStringProperty("hdr-compute-peak", hdrComputePeak ? "yes" : "no") + setStringProperty("target-prim", targetPrimaries) + setStringProperty("target-trc", targetTransfer) + setStringProperty("deband", deband ? "yes" : "no") + setStringProperty("interpolation", interpolation ? "yes" : "no") + setVideoEqualizer("brightness", brightness) + setVideoEqualizer("contrast", contrast) + setVideoEqualizer("saturation", saturation) + setVideoEqualizer("gamma", gamma) + } + func setSpeed(_ speed: Float) { guard mpv != nil else { return } var s = Double(speed) @@ -443,14 +509,14 @@ final class MPVPlayerViewController: UIViewController { guard mpv != nil else { return } switch mode { case 1: // Fill - checkError(mpv_set_option_string(mpv, "panscan", "1.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) + setStringProperty("panscan", "1.0") + setStringProperty("video-unscaled", "no") case 2: // Zoom - checkError(mpv_set_option_string(mpv, "panscan", "1.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) + setStringProperty("panscan", "1.0") + setStringProperty("video-unscaled", "no") default: // Fit - checkError(mpv_set_option_string(mpv, "panscan", "0.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) + setStringProperty("panscan", "0.0") + setStringProperty("video-unscaled", "no") } } @@ -465,7 +531,7 @@ final class MPVPlayerViewController: UIViewController { func selectSubtitle(_ trackId: Int) { guard mpv != nil else { return } if trackId < 0 { - checkError(mpv_set_option_string(mpv, "sid", "no")) + setStringProperty("sid", "no") } else { var id = Int64(trackId) mpv_set_property(mpv, "sid", MPV_FORMAT_INT64, &id) @@ -488,7 +554,7 @@ final class MPVPlayerViewController: UIViewController { command("sub-remove", args: ["\(id)"], checkForErrors: false) } } - checkError(mpv_set_option_string(mpv, "sid", "no")) + setStringProperty("sid", "no") } func removeExternalSubtitlesAndSelect(_ trackId: Int) { @@ -505,7 +571,7 @@ final class MPVPlayerViewController: UIViewController { if trackId >= 0 { selectSubtitle(trackId) } else { - checkError(mpv_set_option_string(mpv, "sid", "no")) + setStringProperty("sid", "no") } } @@ -824,6 +890,17 @@ final class MPVPlayerViewController: UIViewController { mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data) } + private func setStringProperty(_ name: String, _ value: String) { + guard mpv != nil else { return } + checkError(mpv_set_property_string(mpv, name, value)) + } + + private func setVideoEqualizer(_ name: String, _ value: Int) { + guard mpv != nil else { return } + var clamped = Int64(max(-100, min(100, value))) + checkError(mpv_set_property(mpv, name, MPV_FORMAT_INT64, &clamped)) + } + private func getInt(_ name: String) -> Int { guard mpv != nil else { return 0 } var data = Int64() diff --git a/stremio-community-v5 b/stremio-community-v5 new file mode 160000 index 00000000..da0783df --- /dev/null +++ b/stremio-community-v5 @@ -0,0 +1 @@ +Subproject commit da0783dfd8e067b97a95d11e33c78936f523c4d3