mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-22 17:52:06 +00:00
Merge branch 'Norwegian-Translation' of https://github.com/SimSalabimse/NuvioMobile into Norwegian-Translation
This commit is contained in:
commit
4b21fe2d85
57 changed files with 5473 additions and 499 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -179,12 +179,7 @@ fun NuvioPosterCard(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp),
|
||||
)
|
||||
NuvioPosterWatchedOverlay(isWatched = isWatched)
|
||||
}
|
||||
if (shouldShowTitleBelow) {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ object HomeCatalogSettingsRepository {
|
|||
enforcePinnedCollectionsAtTop()
|
||||
publish()
|
||||
persist()
|
||||
HomeRepository.applyCurrentSettings()
|
||||
}
|
||||
|
||||
internal fun snapshot(): HomeCatalogSettingsSnapshot {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) } },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=62
|
||||
MARKETING_VERSION=0.1.0
|
||||
CURRENT_PROJECT_VERSION=64
|
||||
MARKETING_VERSION=0.1.22
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
stremio-community-v5
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit da0783dfd8e067b97a95d11e33c78936f523c4d3
|
||||
Loading…
Reference in a new issue