Merge branch 'Norwegian-Translation' of https://github.com/SimSalabimse/NuvioMobile into Norwegian-Translation

This commit is contained in:
SimSalabimse 2026-05-20 20:43:50 +02:00
commit 4b21fe2d85
57 changed files with 5473 additions and 499 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1331,4 +1331,4 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
</resources>
</resources>

View file

@ -1,4 +1,5 @@
<resources>
<string name="about_licenses_attributions_subtitle">Źródła danych, podziękowania i licencje platformy</string>
<string name="about_supporters_contributors_subtitle">Osoby wspierające i współtworzące projekt</string>
<string name="action_back">Wstecz</string>
<string name="action_cancel">Anuluj</string>
@ -17,6 +18,8 @@
<string name="action_resume">Wznów</string>
<string name="action_retry">Ponów</string>
<string name="action_save">Zapisz</string>
<string name="action_saving">Zapisywanie…</string>
<string name="action_validate">Sprawdź</string>
<string name="addon_installing">Instalowanie</string>
<string name="addon_title">Dodatki</string>
<string name="addons_badge_active">Aktywny</string>
@ -110,29 +113,38 @@
<string name="collections_editor_tmdb_production_mode">Produkcja</string>
<string name="collections_editor_tmdb_network_mode">Stacja</string>
<string name="collections_editor_tmdb_collection_mode">Kolekcja</string>
<string name="collections_editor_tmdb_person_mode">Osoba</string>
<string name="collections_editor_tmdb_director_mode">Reżyser</string>
<string name="collections_editor_tmdb_custom_mode">Niestandardowy</string>
<string name="collections_editor_tmdb_help_presets">Wybierz gotowe źródło. Możesz je edytować lub usunąć po dodaniu.</string>
<string name="collections_editor_tmdb_help_list">Wklej publiczny URL listy TMDB lub sam numer z URL.</string>
<string name="collections_editor_tmdb_help_production">Wyszukaj po nazwie studia lub wklej ID/URL firmy TMDB i dodaj bezpośrednio.</string>
<string name="collections_editor_tmdb_help_network">Wprowadź ID stacji. Popularne stacje są dostępne w szablonach i filtrach.</string>
<string name="collections_editor_tmdb_help_collection">Wyszukaj nazwę kolekcji filmów lub wklej ID kolekcji z TMDB.</string>
<string name="collections_editor_tmdb_help_person">Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii aktora.</string>
<string name="collections_editor_tmdb_help_director">Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii reżysera.</string>
<string name="collections_editor_tmdb_help_discover">Zbuduj dynamiczny wiersz TMDB z opcjonalnymi filtrami. Zostaw pola puste, gdy nie potrzebujesz danego filtra.</string>
<string name="collections_editor_tmdb_public_list">Publiczna lista TMDB</string>
<string name="collections_editor_tmdb_network_id">ID stacji</string>
<string name="collections_editor_tmdb_collection_id">ID kolekcji</string>
<string name="collections_editor_tmdb_person_id">ID osoby</string>
<string name="collections_editor_tmdb_company_search">Nazwa firmy produkcyjnej, ID lub URL</string>
<string name="collections_editor_tmdb_id_or_url">ID lub URL TMDB</string>
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 lub 8504994</string>
<string name="collections_editor_tmdb_network_placeholder">213 dla Netflix, 49 dla HBO, 2739 dla Disney+</string>
<string name="collections_editor_tmdb_collection_placeholder">10 dla kolekcji Star Wars</string>
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420 lub URL firmy</string>
<string name="collections_editor_tmdb_person_placeholder">31 dla Toma Hanksa lub URL osoby</string>
<string name="collections_editor_tmdb_search_helper">Przykłady: Marvel Studios, 420 lub https://www.themoviedb.org/company/420.</string>
<string name="collections_editor_tmdb_collection_helper">Przykład: Star Wars Collection, Harry Potter Collection lub URL kolekcji.</string>
<string name="collections_editor_tmdb_network_helper">Przykładowe ID: Netflix 213, HBO 49, Disney+ 2739.</string>
<string name="collections_editor_tmdb_list_helper">Przykład: https://www.themoviedb.org/list/8504994 lub 8504994.</string>
<string name="collections_editor_tmdb_person_helper">Przykład: https://www.themoviedb.org/person/31-tom-hanks lub 31.</string>
<string name="collections_editor_tmdb_display_title">Wyświetlany tytuł</string>
<string name="collections_editor_tmdb_title_helper">Wyświetlany jako nazwa wiersza/karty. Jeśli pusty, Nuvio utworzy go ze źródła.</string>
<string name="collections_editor_tmdb_title_placeholder">Filmy Marvela, Oryginały Netflix, Pixar</string>
<string name="collections_editor_tmdb_person_title_placeholder">Filmy Toma Hanksa, Ulubieni aktorzy</string>
<string name="collections_editor_tmdb_director_title_placeholder">Filmy Christophera Nolana, Ulubieni reżyserzy</string>
<string name="collections_editor_tmdb_discover_title_placeholder">Najlepsze filmy akcji, Koreańskie dramy, Animacja 2024</string>
<string name="collections_editor_tmdb_search_results">Wyniki wyszukiwania</string>
<string name="collections_editor_tmdb_collection">Kolekcja TMDB</string>
@ -179,6 +191,27 @@
<string name="collections_editor_tmdb_presets">Szablony</string>
<string name="collections_editor_tmdb_search">Szukaj</string>
<string name="collections_editor_add_source">Dodaj źródło</string>
<string name="collections_editor_add_trakt_source">Dodaj listę Trakt</string>
<string name="collections_editor_edit_trakt_source">Edytuj listę Trakt</string>
<string name="collections_editor_trakt_sources">Listy Trakt</string>
<string name="collections_editor_trakt_list">Lista Trakt</string>
<string name="collections_editor_trakt_input_placeholder">Szukaj tytułu, URL Trakt lub ID listy</string>
<string name="collections_editor_trakt_input_helper">Użyj publicznego URL listy Trakt, numerycznego ID listy lub wyszukaj po nazwie.</string>
<string name="collections_editor_trakt_title_placeholder">Weekendowe filmy, Laureaci nagród</string>
<string name="collections_editor_trakt_search_results">Wyniki wyszukiwania</string>
<string name="collections_editor_trakt_trending">Popularne teraz</string>
<string name="collections_editor_trakt_popular">Popularne listy</string>
<string name="collections_editor_trakt_direction">Kierunek</string>
<string name="collections_editor_trakt_ascending">Rosnąco</string>
<string name="collections_editor_trakt_descending">Malejąco</string>
<string name="collections_editor_trakt_sort_list_order">Kolejność listy</string>
<string name="collections_editor_trakt_sort_recently_added">Ostatnio dodane</string>
<string name="collections_editor_trakt_sort_title">Tytuł</string>
<string name="collections_editor_trakt_sort_released">Data premiery</string>
<string name="collections_editor_trakt_sort_runtime">Czas trwania</string>
<string name="collections_editor_trakt_sort_popular">Popularne</string>
<string name="collections_editor_trakt_sort_percentage">Procent</string>
<string name="collections_editor_trakt_sort_votes">Głosy</string>
<string name="collections_editor_tmdb_genre_action">Akcja</string>
<string name="collections_editor_tmdb_genre_adventure">Przygodowy</string>
<string name="collections_editor_tmdb_genre_animation">Animacja</string>
@ -212,13 +245,29 @@
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
<string name="collections_editor_tmdb_sort_original">Oryginalna</string>
<string name="collections_editor_tmdb_sort_popular">Popularne</string>
<string name="collections_editor_tmdb_sort_top_rated">Najwyżej oceniane</string>
<string name="collections_editor_tmdb_sort_recent">Ostatnie</string>
<string name="collections_editor_tmdb_sort_vote_count">Najczęściej głosowane</string>
<string name="collections_editor_tmdb_watch_region">Region dostępności</string>
<string name="collections_editor_tmdb_watch_region_helper">Kod kraju ISO 3166-1, w którym tytuł jest dostępny. Przykład: US, GB.</string>
<string name="collections_editor_tmdb_quick_watch_regions">Popularne regiony dostępności</string>
<string name="collections_editor_tmdb_watch_providers">ID platform streamingowych</string>
<string name="collections_editor_tmdb_watch_providers_helper">Użyj ID platform TMDB. Oddziel wiele przecinkami dla AND lub pionowymi kreskami dla OR.</string>
<string name="collections_editor_tmdb_watch_providers_placeholder">8|337|350</string>
<string name="collections_editor_tmdb_quick_watch_providers">Popularne platformy streamingowe</string>
<string name="collections_editor_tmdb_watch_provider_netflix">Netflix</string>
<string name="collections_editor_tmdb_watch_provider_prime">Prime Video</string>
<string name="collections_editor_tmdb_watch_provider_disney">Disney+</string>
<string name="collections_editor_tmdb_watch_provider_apple">Apple TV+</string>
<string name="collections_editor_tmdb_watch_provider_hulu">Hulu</string>
<string name="collections_editor_tmdb_subtitle_list">Lista TMDB</string>
<string name="collections_editor_tmdb_subtitle_movie_collection">Kolekcja filmów TMDB</string>
<string name="collections_editor_tmdb_subtitle_production">Produkcja</string>
<string name="collections_editor_tmdb_subtitle_network">Stacja</string>
<string name="collections_editor_tmdb_subtitle_person">Osoba</string>
<string name="collections_editor_tmdb_subtitle_director">Reżyser</string>
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
<string name="collections_empty_subtitle">Utwórz jedną, aby uporządkować katalogi.</string>
<string name="collections_empty_title">Brak kolekcji</string>
@ -331,8 +380,10 @@
<string name="compose_settings_page_appearance">Wygląd</string>
<string name="compose_settings_page_content_discovery">Treści i odkrywanie</string>
<string name="compose_settings_page_continue_watching">Kontynuuj oglądanie</string>
<string name="compose_settings_page_debrid">Debrid</string>
<string name="compose_settings_page_homescreen">Ekran główny</string>
<string name="compose_settings_page_integrations">Integracje</string>
<string name="compose_settings_page_licenses_attributions">Licencje i atrybucje</string>
<string name="compose_settings_page_mdblist_ratings">Oceny MDBList</string>
<string name="compose_settings_page_meta_screen">Ekran metadanych</string>
<string name="compose_settings_page_notifications">Powiadomienia</string>
@ -358,6 +409,31 @@
<string name="compose_settings_root_switch_profile_description">Przełącz na inny profil.</string>
<string name="compose_settings_root_switch_profile_title">Przełącz profil</string>
<string name="compose_settings_root_trakt_description">Połącz Trakt, synchronizuj listy obserwowanych i zapisuj tytuły bezpośrednio w Trakt.</string>
<string name="settings_search_empty">Nie znaleziono ustawień.</string>
<string name="settings_search_placeholder">Szukaj ustawień...</string>
<string name="settings_search_results_section">WYNIKI</string>
<string name="settings_licenses_attributions_section_app">LICENCJA APLIKACJI</string>
<string name="settings_licenses_attributions_section_data">DANE I USŁUGI</string>
<string name="settings_licenses_attributions_section_playback">LICENCJA ODTWARZANIA</string>
<string name="settings_licenses_attributions_nuvio_title">Nuvio Mobile</string>
<string name="settings_licenses_attributions_nuvio_body">Kod źródłowy i warunki licencji są dostępne w repozytorium projektu.</string>
<string name="settings_licenses_attributions_nuvio_license">Licencjonowany na podstawie GNU General Public License v3.0.</string>
<string name="settings_licenses_attributions_tmdb_title">The Movie Database (TMDB)</string>
<string name="settings_licenses_attributions_tmdb_body">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.</string>
<string name="settings_licenses_attributions_imdb_title">Niekomercyjne zbiory danych IMDb</string>
<string name="settings_licenses_attributions_imdb_body">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.</string>
<string name="settings_licenses_attributions_trakt_title">Trakt</string>
<string name="settings_licenses_attributions_trakt_body">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.</string>
<string name="settings_licenses_attributions_mdblist_title">MDBList</string>
<string name="settings_licenses_attributions_mdblist_body">Nuvio korzysta z MDBList do ocen i danych zewnętrznych dostawców ocen. Nuvio nie jest powiązane z MDBList ani przez nie wspierane.</string>
<string name="settings_licenses_attributions_introdb_title">IntroDB</string>
<string name="settings_licenses_attributions_introdb_body">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.</string>
<string name="settings_licenses_attributions_mpvkit_title">MPVKit</string>
<string name="settings_licenses_attributions_mpvkit_body">Używany do odtwarzania w wersjach na iOS.</string>
<string name="settings_licenses_attributions_mpvkit_license">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.</string>
<string name="settings_licenses_attributions_exoplayer_title">AndroidX Media3 ExoPlayer 1.8.0</string>
<string name="settings_licenses_attributions_exoplayer_body">Używany do odtwarzania w wersjach na Androida.</string>
<string name="settings_licenses_attributions_exoplayer_license">Licencjonowany na podstawie Apache License, wersja 2.0.</string>
<string name="compose_trakt_list_picker_loading">Ładowanie list Trakt…</string>
<string name="compose_trakt_list_picker_subtitle">Wybierz, gdzie zapisać ten tytuł w Trakt</string>
<string name="action_donate">Wesprzyj</string>
@ -416,6 +492,8 @@
<string name="settings_appearance_app_language">Język aplikacji</string>
<string name="settings_appearance_app_language_sheet_title">Wybierz język</string>
<string name="settings_appearance_continue_watching_description">Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">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.</string>
<string name="settings_appearance_poster_customization_description">Dostosuj szerokość i zaokrąglenie rogów kart plakatów.</string>
<string name="settings_appearance_section_display">WYŚWIETLANIE</string>
<string name="settings_appearance_section_home">EKRAN GŁÓWNY</string>
@ -442,9 +520,14 @@
<string name="settings_homescreen_selected_count">%1$d z %2$d wybranych</string>
<string name="settings_homescreen_show_hero">Pokaż Hero</string>
<string name="settings_homescreen_show_hero_description">Wyświetl wyróżnioną karuzelę hero na górze ekranu głównego. Wybierz do 2 katalogów źródłowych poniżej.</string>
<string name="layout_hide_unreleased">Ukryj niewydane treści</string>
<string name="layout_hide_unreleased_sub">Ukryj filmy i seriale, które nie zostały jeszcze wydane.</string>
<string name="settings_homescreen_hide_catalog_underline">Ukryj podkreślenie katalogu</string>
<string name="settings_homescreen_hide_catalog_underline_description">Usuń linię akcentu pod tytułami katalogów i kolekcji w całej aplikacji.</string>
<string name="settings_homescreen_summary">%1$d z %2$d katalogów widocznych • %3$d źródeł hero wybranych</string>
<string name="settings_homescreen_summary_hint">Otwórz katalog tylko wtedy, gdy chcesz zmienić jego nazwę lub kolejność.</string>
<string name="settings_homescreen_visible">Widoczne</string>
<string name="settings_hide_secret">Ukryj wartość</string>
<string name="settings_playback_subtitle">Odtwarzacz, napisy i automatyczne odtwarzanie</string>
<string name="settings_poster_card_radius">Zaokrąglenie karty</string>
<string name="settings_poster_card_style">STYL KARTY PLAKATU</string>
@ -469,8 +552,19 @@
<string name="settings_poster_width_dense">Gęsty</string>
<string name="settings_poster_width_large">Duży</string>
<string name="settings_poster_width_standard">Standardowy</string>
<string name="settings_show_secret">Pokaż wartość</string>
<string name="settings_continue_watching_resume_prompt_description">Pokaż okno kontynuowania od miejsca, w którym skończyłeś, po otwarciu aplikacji po wyjściu z odtwarzacza.</string>
<string name="settings_continue_watching_resume_prompt_title">Monit o wznowienie przy uruchomieniu</string>
<string name="settings_continue_watching_blur_next_up_description">Rozmyj miniatury następnych odcinków w Kontynuuj oglądanie, aby uniknąć spoilerów.</string>
<string name="settings_continue_watching_blur_next_up_title">Rozmyj nieobejrzane w Kontynuuj oglądanie</string>
<string name="settings_continue_watching_show_unaired_next_up_description">Uwzględnij nadchodzące odcinki w Kontynuuj oglądanie przed ich emisją.</string>
<string name="settings_continue_watching_show_unaired_next_up_title">Pokaż niewyemitowane następne odcinki</string>
<string name="settings_continue_watching_section_sort_order">KOLEJNOŚĆ SORTOWANIA</string>
<string name="settings_continue_watching_sort_mode_title">Kolejność sortowania</string>
<string name="settings_continue_watching_sort_mode_default">Domyślna</string>
<string name="settings_continue_watching_sort_mode_default_desc">Sortuj wszystkie elementy według czasu</string>
<string name="settings_continue_watching_sort_mode_streaming">Styl streamingowy</string>
<string name="settings_continue_watching_sort_mode_streaming_desc">Wydane najpierw, nadchodzące na końcu</string>
<string name="settings_continue_watching_section_card_style">STYL KARTY</string>
<string name="settings_continue_watching_section_on_launch">PRZY URUCHOMIENIU</string>
<string name="settings_continue_watching_section_up_next_behavior">ZACHOWANIE NASTĘPNEGO</string>
@ -483,6 +577,8 @@
<string name="settings_continue_watching_style_wide_description">Pozioma karta z informacjami</string>
<string name="settings_continue_watching_up_next_description">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.</string>
<string name="settings_continue_watching_up_next_title">Następny od najdalszego odcinka</string>
<string name="settings_continue_watching_use_episode_thumbnails_description">Preferuj miniatury odcinków, gdy są dostępne.</string>
<string name="settings_continue_watching_use_episode_thumbnails_title">Preferuj miniatury odcinków w Kontynuuj oglądanie</string>
<string name="settings_content_discovery_section_home">EKRAN GŁÓWNY</string>
<string name="settings_content_discovery_section_sources">ŹRÓDŁA</string>
<string name="settings_content_discovery_addons_description">Instaluj, usuwaj, odświeżaj i sortuj źródła treści.</string>
@ -493,6 +589,34 @@
<string name="settings_integrations_section_title">INTEGRACJE</string>
<string name="settings_integrations_tmdb_description">Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko.</string>
<string name="settings_integrations_mdblist_description">Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów.</string>
<string name="settings_integrations_debrid_description">Eksperymentalne źródła z kont chmurowych</string>
<string name="settings_debrid_section_title">Debrid</string>
<string name="settings_debrid_experimental_notice">Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości.</string>
<string name="settings_debrid_enable">Włącz źródła</string>
<string name="settings_debrid_enable_description">Pokaż odtwarzalne wyniki z połączonych kont.</string>
<string name="settings_debrid_add_key_first">Najpierw dodaj klucz API.</string>
<string name="settings_debrid_section_providers">Konto</string>
<string name="settings_debrid_provider_torbox_description">Połącz swoje konto Torbox.</string>
<string name="settings_debrid_dialog_title">Klucz API Torbox</string>
<string name="settings_debrid_dialog_subtitle">Wprowadź swój klucz API Torbox.</string>
<string name="settings_debrid_dialog_placeholder">Wprowadź klucz API Torbox</string>
<string name="settings_debrid_not_set">Nie ustawiono</string>
<string name="settings_debrid_section_instant_playback">Natychmiastowe odtwarzanie</string>
<string name="settings_debrid_prepare_instant_playback">Przygotuj linki</string>
<string name="settings_debrid_prepare_instant_playback_description">Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania.</string>
<string name="settings_debrid_prepare_stream_count">Źródła do przygotowania</string>
<string name="settings_debrid_prepare_stream_count_warning">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.</string>
<string name="settings_debrid_prepare_count_one">1 źródło</string>
<string name="settings_debrid_prepare_count_many">%1$d źródeł</string>
<string name="settings_debrid_section_formatting">Formatowanie</string>
<string name="settings_debrid_name_template">Szablon nazwy</string>
<string name="settings_debrid_name_template_description">Kontroluje sposób wyświetlania nazw źródeł.</string>
<string name="settings_debrid_description_template">Szablon opisu</string>
<string name="settings_debrid_description_template_description">Kontroluje metadane wyświetlane pod każdym źródłem.</string>
<string name="settings_debrid_formatter_reset_title">Resetuj formatowanie</string>
<string name="settings_debrid_formatter_reset_subtitle">Przywróć domyślne formatowanie źródeł.</string>
<string name="settings_debrid_key_valid">Klucz API zweryfikowany.</string>
<string name="settings_debrid_key_invalid">Nie udało się zweryfikować tego klucza API.</string>
<string name="settings_mdb_add_api_key_first">Dodaj klucz API MDBList poniżej przed włączeniem ocen.</string>
<string name="settings_mdb_api_key_description">Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj.</string>
<string name="settings_mdb_api_key_label">Klucz API</string>
@ -522,6 +646,8 @@
<string name="settings_meta_episode_style_list_description">Karty ze szczegółami na pierwszym planie</string>
<string name="settings_meta_episodes">Odcinki</string>
<string name="settings_meta_episodes_description">Sezony i lista odcinków dla seriali.</string>
<string name="settings_meta_blur_unwatched_episodes">Rozmyj nieobejrzane odcinki</string>
<string name="settings_meta_blur_unwatched_episodes_description">Rozmyj miniatury odcinków do momentu obejrzenia, aby uniknąć spoilerów.</string>
<string name="settings_meta_group_label">Grupa %1$d</string>
<string name="settings_meta_more_like_this">Podobne</string>
<string name="settings_meta_more_like_this_description">Wiersz rekomendacji.</string>
@ -588,6 +714,10 @@
<string name="settings_playback_anime_skip">Anime Skip</string>
<string name="settings_playback_anime_skip_client_id">ID klienta AnimeSkip</string>
<string name="settings_playback_anime_skip_client_id_description">Wprowadź ID klienta API AnimeSkip. Pobierz je na anime-skip.com.</string>
<string name="settings_playback_intro_submit_enabled">Włącz przesyłanie intro</string>
<string name="settings_playback_intro_submit_enabled_description">Pokaż przycisk do przesyłania znaczników intro/outro do bazy społeczności.</string>
<string name="settings_playback_introdb_api_key">Klucz API IntroDB</string>
<string name="settings_playback_introdb_api_key_description">Wprowadź klucz API IntroDB, aby przesyłać znaczniki czasowe. Wymagany do przesyłania.</string>
<string name="settings_playback_anime_skip_description">Wyszukuj również w AnimeSkip znaczniki pomijania (wymaga ID klienta).</string>
<string name="settings_playback_auto_play_next_episode">Automatyczne odtwarzanie następnego odcinka</string>
<string name="settings_playback_auto_play_next_episode_description">Automatycznie znajdź i odtwórz następny odcinek po osiągnięciu progu.</string>
@ -603,6 +733,11 @@
<string name="settings_playback_duration_hours">%1$d godzin</string>
<string name="settings_playback_enable_libass">Włącz libass</string>
<string name="settings_playback_enable_libass_description">Użyj libass do renderowania napisów ASS/SSA zamiast domyślnego renderera.</string>
<string name="settings_playback_external_player">Zewnętrzny odtwarzacz</string>
<string name="settings_playback_external_player_app">Aplikacja zewnętrznego odtwarzacza</string>
<string name="settings_playback_external_player_description_android">Otwórz nowe odtwarzanie w domyślnej aplikacji wideo Androida lub selektorze systemowym.</string>
<string name="settings_playback_external_player_description_ios">Otwórz nowe odtwarzanie w wybranym zainstalowanym odtwarzaczu.</string>
<string name="settings_playback_external_player_none_available">Brak zainstalowanych obsługiwanych zewnętrznych odtwarzaczy</string>
<string name="settings_playback_hold_speed">Prędkość przy przytrzymaniu</string>
<string name="settings_playback_hold_to_speed">Przytrzymaj, aby przyspieszyć</string>
<string name="settings_playback_hold_to_speed_description">Przytrzymaj dowolne miejsce na powierzchni odtwarzacza, aby tymczasowo zwiększyć prędkość odtwarzania.</string>
@ -621,6 +756,8 @@
<string name="settings_playback_option_none">Brak</string>
<string name="settings_playback_prefer_binge_group">Preferuj grupę binge</string>
<string name="settings_playback_prefer_binge_group_description">Przy automatycznym odtwarzaniu preferuj strumień z tej samej grupy binge co bieżący.</string>
<string name="settings_playback_reuse_binge_group">Ponownie użyj grupy binge</string>
<string name="settings_playback_reuse_binge_group_description">Zapamiętaj i ponownie użyj ostatniej grupy binge między sesjami (Kontynuuj oglądanie, Szczegóły itp.).</string>
<string name="settings_playback_preferred_audio_language">Preferowany język audio</string>
<string name="settings_playback_preferred_subtitle_language">Preferowany język napisów</string>
<string name="settings_playback_presets">Szablony</string>
@ -744,6 +881,28 @@
<string name="settings_trakt_open_login">Otwórz logowanie Trakt</string>
<string name="settings_trakt_save_actions_description">Twoje akcje zapisywania mogą teraz celować w listę obserwowanych i osobiste listy Trakt.</string>
<string name="settings_trakt_sign_in_description">Zaloguj się w Trakt, aby włączyć zapisywanie na listach i tryb biblioteki Trakt.</string>
<string name="trakt_library_source_title">Źródło biblioteki</string>
<string name="trakt_library_source_subtitle">Wybierz, której biblioteki używać do zapisywania i przeglądania kolekcji</string>
<string name="trakt_library_source_dialog_title">Źródło biblioteki</string>
<string name="trakt_library_source_dialog_subtitle">Wybierz, gdzie zapisywać i zarządzać elementami biblioteki</string>
<string name="trakt_library_source_trakt">Trakt</string>
<string name="trakt_library_source_nuvio">Biblioteka Nuvio</string>
<string name="trakt_library_source_trakt_selected">Wybrano bibliotekę Trakt</string>
<string name="trakt_library_source_nuvio_selected">Wybrano bibliotekę Nuvio</string>
<string name="trakt_watch_progress_title">Postęp oglądania</string>
<string name="trakt_watch_progress_subtitle">Wybierz, które źródło postępu obsługuje wznawianie i Kontynuuj oglądanie</string>
<string name="trakt_watch_progress_dialog_title">Postęp oglądania</string>
<string name="trakt_watch_progress_dialog_subtitle">Wybierz, czy wznawianie i Kontynuuj oglądanie powinno korzystać z Trakt czy Nuvio Sync, podczas gdy scrobblowanie Trakt pozostaje aktywne.</string>
<string name="trakt_watch_progress_source_trakt">Trakt</string>
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
<string name="trakt_watch_progress_trakt_selected">Źródło postępu ustawione na Trakt</string>
<string name="trakt_watch_progress_nuvio_selected">Źródło postępu ustawione na Nuvio Sync</string>
<string name="trakt_continue_watching_window">Okno Kontynuuj oglądanie</string>
<string name="trakt_continue_watching_subtitle">Historia Trakt uwzględniana w Kontynuuj oglądanie</string>
<string name="trakt_cw_window_title">Okno Kontynuuj oglądanie</string>
<string name="trakt_cw_window_subtitle">Wybierz, ile aktywności Trakt ma się pojawiać w Kontynuuj oglądanie.</string>
<string name="trakt_all_history">Cała historia</string>
<string name="trakt_days_format">%1$d dni</string>
<string name="source_audience_score">Ocena widzów</string>
<string name="source_imdb">IMDb</string>
<string name="source_letterboxd">Letterboxd</string>
@ -934,9 +1093,14 @@
<string name="pin_locked_try_again">Zablokowane. Spróbuj ponownie za %1$ds</string>
<string name="profile_avatar_options_pending">Opcje awatarów pojawią się tutaj po załadowaniu katalogu.</string>
<string name="profile_avatar_selected">Awatar: %1$s</string>
<string name="profile_avatar_url_invalid">Wprowadź prawidłowy URL obrazu http:// lub https://.</string>
<string name="profile_choose_avatar">Wybierz awatar</string>
<string name="profile_choose_avatar_below">Wybierz awatar poniżej.</string>
<string name="profile_create_profile">Utwórz profil</string>
<string name="profile_custom_avatar_selected">Wybrano niestandardowy URL awatara.</string>
<string name="profile_custom_avatar_url">Niestandardowy URL awatara</string>
<string name="profile_custom_avatar_url_description">Wklej link do obrazu lub zostaw puste, aby użyć wbudowanego katalogu awatarów.</string>
<string name="profile_custom_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="profile_delete_confirm_message">Wszystkie dane profilu „%1$s" zostaną trwale usunięte.</string>
<string name="profile_delete_title">Usuń profil</string>
<string name="profile_edit_add_title">Dodaj profil</string>
@ -968,6 +1132,8 @@
<string name="streams_checking_more_addons">Sprawdzanie kolejnych dodatków…</string>
<string name="streams_copy_link">Kopiuj link strumienia</string>
<string name="streams_download_file">Pobierz plik</string>
<string name="streams_open_external_player">Otwórz w zewnętrznym odtwarzaczu</string>
<string name="streams_open_internal_player">Otwórz w wewnętrznym odtwarzaczu</string>
<string name="streams_empty_load_failed_message">Zainstalowane dodatki strumieni nie zwróciły prawidłowej odpowiedzi.</string>
<string name="streams_empty_load_failed_title">Nie można załadować strumieni</string>
<string name="streams_empty_no_addons_message">Najpierw zainstaluj dodatek, aby załadować strumienie dla tego tytułu.</string>
@ -987,6 +1153,13 @@
<string name="streams_resume_from_percent">Wznów od %1$d%</string>
<string name="streams_resume_from_time">Wznów od %1$s</string>
<string name="streams_size">ROZMIAR %1$s</string>
<string name="streams_torrent_not_supported">Ten typ strumienia nie jest obsługiwany</string>
<string name="debrid_missing_api_key">Dodaj klucz API Debrid w Ustawieniach.</string>
<string name="debrid_stream_stale">Ten wynik Debrid wygasł. Odświeżanie strumieni.</string>
<string name="debrid_resolve_failed">Nie udało się rozwiązać tego strumienia Debrid.</string>
<string name="external_player_failed">Nie udało się otworzyć zewnętrznego odtwarzacza</string>
<string name="external_player_not_configured">Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach</string>
<string name="external_player_unavailable">Brak dostępnego zewnętrznego odtwarzacza</string>
<string name="trailer_close">Zamknij zwiastun</string>
<string name="trailer_unable_to_play">Nie można odtworzyć zwiastuna</string>
<string name="trakt_lists_load_failed">Nie udało się załadować list Trakt</string>
@ -1032,6 +1205,7 @@
<string name="downloads_live_failed">Pobieranie nie powiodło się</string>
<string name="downloads_live_paused">Wstrzymano %1$s</string>
<string name="library_remove_confirm">Usuń</string>
<string name="library_remove_from_list_message">Usunąć %1$s z %2$s?</string>
<string name="library_remove_message">Usunąć %1$s z biblioteki?</string>
<string name="library_remove_title">Usunąć z biblioteki?</string>
<string name="media_movie">Film</string>
@ -1075,6 +1249,7 @@
<string name="collections_import_error_folder_blank_id">Folder %1$d w „%2$s" ma puste id.</string>
<string name="collections_import_error_folder_blank_title">Folder „%1$s" w „%2$s" ma pusty tytuł.</string>
<string name="collections_import_error_source_blank_fields">Źródło %1$d w folderze „%2$s" ma puste pola.</string>
<string name="collections_import_error_trakt_list_id">Źródło %1$d w folderze '%2$s' nie ma ID listy Trakt.</string>
<string name="collections_import_error_invalid_json">Nieprawidłowy JSON: %1$s</string>
<string name="collections_folder_addon_not_found">Nie znaleziono dodatku: %1$s</string>
<string name="date_month_january">Styczeń</string>
@ -1148,6 +1323,14 @@
<string name="notifications_episode_release_body_generic">Nowy odcinek jest już dostępny</string>
<string name="notifications_episode_release_body_title">%1$s jest już dostępny</string>
<string name="notifications_channel_episode_releases_name">Premiery odcinków</string>
<string name="parental_alcohol">Alkohol/Narkotyki</string>
<string name="parental_frightening">Przerażające</string>
<string name="parental_nudity">Nagość</string>
<string name="parental_profanity">Wulgaryzmy</string>
<string name="parental_severity_mild">Łagodne</string>
<string name="parental_severity_moderate">Umiarkowane</string>
<string name="parental_severity_severe">Intensywne</string>
<string name="parental_violence">Przemoc</string>
<string name="person_role_creator">Twórca</string>
<string name="person_role_director">Reżyser</string>
<string name="person_role_writer">Scenarzysta</string>

View file

@ -18,6 +18,7 @@
<string name="action_resume">Resume</string>
<string name="action_retry">Retry</string>
<string name="action_save">Save</string>
<string name="action_saving">Saving…</string>
<string name="action_validate">Validate</string>
<string name="addon_installing">Installing</string>
<string name="addon_title">Addons</string>
@ -596,10 +597,15 @@
<string name="settings_debrid_add_key_first">Add an API key first.</string>
<string name="settings_debrid_section_providers">Account</string>
<string name="settings_debrid_provider_torbox_description">Connect your Torbox account.</string>
<string name="settings_debrid_dialog_title">Torbox API Key</string>
<string name="settings_debrid_dialog_subtitle">Enter your Torbox API key.</string>
<string name="settings_debrid_dialog_placeholder">Enter Torbox API key</string>
<string name="settings_debrid_not_set">Not set</string>
<string name="settings_debrid_section_instant_playback">Instant Playback</string>
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>
<string name="settings_debrid_prepare_instant_playback_description">Resolve the first sources before playback starts.</string>
<string name="settings_debrid_prepare_stream_count">Sources to prepare</string>
<string name="settings_debrid_prepare_stream_count_warning">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.</string>
<string name="settings_debrid_prepare_count_one">1 source</string>
<string name="settings_debrid_prepare_count_many">%1$d sources</string>
<string name="settings_debrid_section_formatting">Formatting</string>
@ -607,6 +613,8 @@
<string name="settings_debrid_name_template_description">Controls how source names appear.</string>
<string name="settings_debrid_description_template">Description template</string>
<string name="settings_debrid_description_template_description">Controls the metadata shown under each source.</string>
<string name="settings_debrid_formatter_reset_title">Reset formatting</string>
<string name="settings_debrid_formatter_reset_subtitle">Restore default source formatting.</string>
<string name="settings_debrid_key_valid">API key validated.</string>
<string name="settings_debrid_key_invalid">Could not validate this API key.</string>
<string name="settings_mdb_add_api_key_first">Add your MDBList API key below before turning ratings on.</string>
@ -748,6 +756,8 @@
<string name="settings_playback_option_none">None</string>
<string name="settings_playback_prefer_binge_group">Prefer Binge Group (Next Episode)</string>
<string name="settings_playback_prefer_binge_group_description">Try the same source profile first (same addon/quality group) before normal auto-play rules.</string>
<string name="settings_playback_reuse_binge_group">Reuse Binge Group</string>
<string name="settings_playback_reuse_binge_group_description">Remember and reuse the last binge group across sessions (Continue Watching, Details, etc.).</string>
<string name="settings_playback_preferred_audio_language">Preferred Audio Language</string>
<string name="settings_playback_preferred_subtitle_language">Preferred Language</string>
<string name="settings_playback_presets">Presets</string>

View file

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

View file

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

View file

@ -179,12 +179,7 @@ fun NuvioPosterCard(
}
}
NuvioAnimatedWatchedBadge(
isVisible = isWatched,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp),
)
NuvioPosterWatchedOverlay(isWatched = isWatched)
}
if (shouldShowTitleBelow) {
Text(

View file

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

View file

@ -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<String>,
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<String>,
modifier: Modifier = Modifier,
onCatalogClick: (HomeCatalogSection) -> Unit,
onPosterClick: (MetaPreview) -> Unit,
@ -340,6 +355,7 @@ private fun RowsContent(
} else {
null
},
watchedKeys = watchedKeys,
onPosterClick = { onPosterClick(it) },
)
}

View file

@ -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> = DebridStreamResolution.defaultOrder,
val requiredResolutions: List<DebridStreamResolution> = emptyList(),
val excludedResolutions: List<DebridStreamResolution> = emptyList(),
val preferredQualities: List<DebridStreamQuality> = DebridStreamQuality.defaultOrder,
val requiredQualities: List<DebridStreamQuality> = emptyList(),
val excludedQualities: List<DebridStreamQuality> = emptyList(),
val preferredVisualTags: List<DebridStreamVisualTag> = DebridStreamVisualTag.defaultOrder,
val requiredVisualTags: List<DebridStreamVisualTag> = emptyList(),
val excludedVisualTags: List<DebridStreamVisualTag> = emptyList(),
val preferredAudioTags: List<DebridStreamAudioTag> = DebridStreamAudioTag.defaultOrder,
val requiredAudioTags: List<DebridStreamAudioTag> = emptyList(),
val excludedAudioTags: List<DebridStreamAudioTag> = emptyList(),
val preferredAudioChannels: List<DebridStreamAudioChannel> = DebridStreamAudioChannel.defaultOrder,
val requiredAudioChannels: List<DebridStreamAudioChannel> = emptyList(),
val excludedAudioChannels: List<DebridStreamAudioChannel> = emptyList(),
val preferredEncodes: List<DebridStreamEncode> = DebridStreamEncode.defaultOrder,
val requiredEncodes: List<DebridStreamEncode> = emptyList(),
val excludedEncodes: List<DebridStreamEncode> = emptyList(),
val preferredLanguages: List<DebridStreamLanguage> = emptyList(),
val requiredLanguages: List<DebridStreamLanguage> = emptyList(),
val excludedLanguages: List<DebridStreamLanguage> = emptyList(),
val requiredReleaseGroups: List<String> = emptyList(),
val excludedReleaseGroups: List<String> = emptyList(),
val sortCriteria: List<DebridStreamSortCriterion> = 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)

View file

@ -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<DebridSettings> = _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 <reified T : Enum<T>> enumValueOrDefault(value: String?, default: T): T =
runCatching { enumValueOf<T>(value.orEmpty()) }.getOrDefault(default)
private fun parseStreamPreferences(value: String?): DebridStreamPreferences? {
if (value.isNullOrBlank()) return null
return try {
json.decodeFromString<DebridStreamPreferences>(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> =
DebridStreamResolution.defaultOrder.filter {
it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN
}
private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List<DebridStreamSortCriterion> =
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,
)

View file

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

View file

@ -5,8 +5,8 @@ import com.nuvio.app.features.streams.StreamItem
object DirectDebridStreamFilter {
const val FALLBACK_SOURCE_NAME = "Direct Debrid"
fun filterInstant(streams: List<StreamItem>): List<StreamItem> =
streams
fun filterInstant(streams: List<StreamItem>, settings: DebridSettings? = null): List<StreamItem> {
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<StreamItem>, settings: DebridSettings): List<StreamItem> {
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<Pair<StreamItem, StreamFacts>>,
preferences: DebridStreamPreferences,
): List<Pair<StreamItem, StreamFacts>> {
val resolutionCounts = mutableMapOf<DebridStreamResolution, Int>()
val qualityCounts = mutableMapOf<DebridStreamQuality, Int>()
val result = mutableListOf<Pair<StreamItem, StreamFacts>>()
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<DebridStreamSortCriterion>,
): 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<String>, searchText: String): List<DebridStreamVisualTag> {
val text = (parsedHdr + searchText).joinToString(" ").lowercase()
val tags = mutableListOf<DebridStreamVisualTag>()
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<String>, searchText: String): List<DebridStreamAudioTag> {
val text = (parsedAudio + searchText).joinToString(" ").lowercase()
val tags = mutableListOf<DebridStreamAudioTag>()
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<String>, searchText: String): List<DebridStreamAudioChannel> {
val text = (parsedChannels + searchText).joinToString(" ").lowercase()
val channels = mutableListOf<DebridStreamAudioChannel>()
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 <T> rank(value: T, preferred: List<T>): Int {
val index = preferred.indexOf(value)
return if (index >= 0) index else Int.MAX_VALUE
}
private fun <T> rankAny(values: List<T>, preferred: List<T>): 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<DebridStreamVisualTag>,
val audioTags: List<DebridStreamAudioTag>,
val audioChannels: List<DebridStreamAudioChannel>,
val encode: DebridStreamEncode,
val languages: List<DebridStreamLanguage>,
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,
)
}

View file

@ -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<DirectDebridStreamCacheKey, CachedDirectDebridStreams>()
private val inFlightFetches = mutableMapOf<DirectDebridStreamCacheKey, Deferred<AddonStreamGroup>>()
fun configuredTargets(): List<DirectDebridStreamTarget> {
DebridSettingsRepository.ensureLoaded()
@ -33,6 +48,12 @@ object DirectDebridStreamSource {
}
}
fun sourceNames(): List<String> =
configuredTargets().map { it.addonName }
fun isEnabled(): Boolean =
sourceNames().isNotEmpty()
fun placeholders(): List<AddonStreamGroup> =
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<AddonStreamGroup>()
val errors = mutableListOf<String>()
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<AddonStreamGroup>) : DirectDebridStreamFetchResult()
data class Error(val message: String) : DirectDebridStreamFetchResult()
}

View file

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

View file

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

View file

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

View file

@ -145,6 +145,7 @@ object HomeCatalogSettingsRepository {
enforcePinnedCollectionsAtTop()
publish()
persist()
HomeRepository.applyCurrentSettings()
}
internal fun snapshot(): HomeCatalogSettingsSnapshot {

View file

@ -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<HomeCatalogDefinition> = emptyList()
private var cachedSections: Map<String, HomeCatalogSection> = emptyMap()
private var cachedCollectionHeroItems: List<MetaPreview> = 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<ManagedAddon>, 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<ManagedAddon>,
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<Collection> {
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<Collection>): List<CollectionSource> =
collections
.flatMap { collection -> collection.folders }
.flatMap { folder -> folder.resolvedSources }
.take(HOME_COLLECTION_HERO_SOURCE_LIMIT)
private suspend fun CollectionSource.resolveCollectionHeroItems(addons: List<ManagedAddon>): List<MetaPreview> {
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<MetaPreview>>): List<MetaPreview> {
val iterators = sourceResults.filter { it.isNotEmpty() }.map { it.iterator() }
if (iterators.isEmpty()) return emptyList()
val merged = mutableListOf<MetaPreview>()
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<Collection>,
addons: List<ManagedAddon>,
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

View file

@ -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<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(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<WatchProgressEntry>,
@ -630,6 +717,169 @@ internal fun filterEntriesForTraktContinueWatchingWindow(
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
}
private fun buildTvParityNextUpSeedEntries(
progressEntries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
isTraktProgressActive: Boolean,
preferFurthestEpisode: Boolean,
nowEpochMs: Long,
): List<WatchProgressEntry> {
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<WatchProgressEntry>): List<WatchProgressEntry> {
val merged = linkedMapOf<String, WatchProgressEntry>()
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<WatchProgressEntry>,
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<WatchProgressEntry>(
{ it.seasonNumber ?: -1 },
{ it.episodeNumber ?: -1 },
{ it.lastUpdatedEpochMs },
)
} else {
compareBy<WatchProgressEntry>(
{ 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<WatchProgressEntry>,
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
nextUpSuppressedSeriesIds: Set<String>? = null,
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
todayIsoDate: String = "",
): List<ContinueWatchingItem> {
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<String, Pair<Long, ContinueWatchingItem>>,
visibleContinueWatchingEntries: List<WatchProgressEntry>,
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,

View file

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

View file

@ -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<LibrarySection>,
watchedKeys: Set<String>,
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) } },
)

View file

@ -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 <T> OptionGroup(
title: String,
options: List<T>,
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,
)
}
}
}
}
}
}
}

View file

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

View file

@ -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<String, String>?): Map<String, String> {

View file

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

View file

@ -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<List<AudioTrack>>(emptyList()) }
var subtitleTracks by remember { mutableStateOf<List<SubtitleTrack>>(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>): 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>): 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,

View file

@ -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<Int> = 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,
)
}

View file

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

View file

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

View file

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

View file

@ -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 <T> IosEnumSelectionDialog(
title: String,
options: List<T>,
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(

View file

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

View file

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

View file

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

View file

@ -2,6 +2,34 @@ package com.nuvio.app.features.streams
object StreamAutoPlaySelector {
fun orderAddonStreams(
groups: List<AddonStreamGroup>,
installedOrder: List<String>,
): List<AddonStreamGroup> {
if (groups.isEmpty()) return groups
val addonRankByName = HashMap<String, Int>(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<StreamItem>,
mode: StreamAutoPlayMode,
@ -12,6 +40,7 @@ object StreamAutoPlaySelector {
selectedPlugins: Set<String>,
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) {

View file

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

View file

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

View file

@ -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<WatchProgressEntry> = 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<Unit>? = 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<String, String>): List<WatchProgressEntry> = 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<List<TraktWatchedShowItem>>(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<WatchProgressEntry>): List<WatchProgressEntry> {
@ -436,6 +477,8 @@ object TraktProgressRepository {
private fun invalidateInFlightRefreshes() {
refreshRequestId += 1L
inFlightRefresh?.cancel()
inFlightRefresh = null
}
private fun isLatestRefreshRequest(requestId: Long): Boolean = refreshRequestId == requestId

View file

@ -14,7 +14,11 @@ data class ProgressSyncRecord(
)
interface ProgressSyncAdapter {
suspend fun pull(profileId: Int): List<ProgressSyncRecord>
suspend fun pull(
profileId: Int,
sinceLastWatched: Long? = null,
limit: Int? = null,
): List<ProgressSyncRecord>
suspend fun push(
profileId: Int,

View file

@ -17,11 +17,23 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
encodeDefaults = true
}
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
val params = buildJsonObject { put("p_profile_id", profileId) }
override suspend fun pull(
profileId: Int,
sinceLastWatched: Long?,
limit: Int?,
): List<ProgressSyncRecord> {
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<WatchProgressSyncEntry>()
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

View file

@ -60,6 +60,7 @@ internal object ContinueWatchingEnrichmentCache {
}
private const val storageKey = "cw_enrichment_cache"
private var lastPayloadHash: Int? = null
fun getNextUpSnapshot(): List<CachedNextUpItem> =
loadPayload()?.nextUp ?: emptyList()
@ -75,11 +76,17 @@ internal object ContinueWatchingEnrichmentCache {
fun saveSnapshots(
nextUp: List<CachedNextUpItem>,
inProgress: List<CachedInProgressItem>,
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<CachedEnrichmentPayload>(raw)
}.getOrNull()
}.getOrNull()?.also { payload ->
lastPayloadHash = payload.hashCode()
}
}
}

View file

@ -118,6 +118,7 @@ data class WatchProgressEntry(
data class WatchProgressUiState(
val entries: List<WatchProgressEntry> = emptyList(),
val hasLoadedRemoteProgress: Boolean = false,
) {
val byVideoId: Map<String, WatchProgressEntry>
get() = entries.associateBy { it.videoId }

View file

@ -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<String, WatchProgressEntry> = 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<String, WatchProgressEntry>()
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
},
)
}

View file

@ -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<String> = emptyList(),
codec: String? = null,
audio: List<String> = emptyList(),
channels: List<String> = emptyList(),
languages: List<String> = 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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=62
MARKETING_VERSION=0.1.0
CURRENT_PROJECT_VERSION=64
MARKETING_VERSION=0.1.22

View file

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

1
stremio-community-v5 Submodule

@ -0,0 +1 @@
Subproject commit da0783dfd8e067b97a95d11e33c78936f523c4d3