From 873f81a954ddf74f7ad4dc2532539c9f4f8e16fd Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 21:04:39 +0530 Subject: [PATCH] feat(ios): adding custom tuning for mpv --- .../player/PlayerSettingsStorage.android.kt | 170 ++++++++++ .../features/player/IosVideoSettingsModal.kt | 317 ++++++++++++++++++ .../app/features/player/PlayerControls.kt | 11 + .../nuvio/app/features/player/PlayerEngine.kt | 1 + .../nuvio/app/features/player/PlayerModels.kt | 67 ++++ .../nuvio/app/features/player/PlayerScreen.kt | 20 ++ .../player/PlayerSettingsRepository.kt | 238 +++++++++++++ .../features/player/PlayerSettingsStorage.kt | 28 ++ .../features/settings/PlaybackSettingsPage.kt | 182 ++++++++++ .../app/features/player/NuvioPlayerBridge.kt | 15 + .../app/features/player/PlayerEngine.ios.kt | 32 ++ .../player/PlayerSettingsStorage.ios.kt | 170 ++++++++++ iosApp/iosApp/Player/MPVPlayerBridge.swift | 72 +++- 13 files changed, 1322 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 5cb861a8..d6e11982 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -56,6 +56,20 @@ actual object PlayerSettingsStorage { private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" private const val useLibassKey = "use_libass" private const val libassRenderTypeKey = "libass_render_type" + private const val iosVideoOutputPresetKey = "ios_video_output_preset" + private const val iosToneMappingModeKey = "ios_tone_mapping_mode" + private const val iosTargetPrimariesKey = "ios_target_primaries" + private const val iosTargetTransferKey = "ios_target_transfer" + private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" + private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" + private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" + private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" + private const val iosDebandEnabledKey = "ios_deband_enabled" + private const val iosInterpolationEnabledKey = "ios_interpolation_enabled" + private const val iosBrightnessKey = "ios_brightness" + private const val iosContrastKey = "ios_contrast" + private const val iosSaturationKey = "ios_saturation" + private const val iosGammaKey = "ios_gamma" private val syncKeys = listOf( showLoadingOverlayKey, resizeModeKey, @@ -92,6 +106,20 @@ actual object PlayerSettingsStorage { nextEpisodeThresholdMinutesBeforeEndKey, useLibassKey, libassRenderTypeKey, + iosVideoOutputPresetKey, + iosToneMappingModeKey, + iosTargetPrimariesKey, + iosTargetTransferKey, + iosHardwareDecoderModeKey, + iosExtendedDynamicRangeEnabledKey, + iosTargetColorspaceHintEnabledKey, + iosHdrComputePeakEnabledKey, + iosDebandEnabledKey, + iosInterpolationEnabledKey, + iosBrightnessKey, + iosContrastKey, + iosSaturationKey, + iosGammaKey, ) private var preferences: SharedPreferences? = null @@ -652,6 +680,120 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadIosVideoOutputPreset(): String? = + preferences?.getString(ProfileScopedKey.of(iosVideoOutputPresetKey), null) + + actual fun saveIosVideoOutputPreset(preset: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosVideoOutputPresetKey), preset)?.apply() + } + + actual fun loadIosToneMappingMode(): String? = + preferences?.getString(ProfileScopedKey.of(iosToneMappingModeKey), null) + + actual fun saveIosToneMappingMode(mode: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosToneMappingModeKey), mode)?.apply() + } + + actual fun loadIosTargetPrimaries(): String? = + preferences?.getString(ProfileScopedKey.of(iosTargetPrimariesKey), null) + + actual fun saveIosTargetPrimaries(primaries: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosTargetPrimariesKey), primaries)?.apply() + } + + actual fun loadIosTargetTransfer(): String? = + preferences?.getString(ProfileScopedKey.of(iosTargetTransferKey), null) + + actual fun saveIosTargetTransfer(transfer: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosTargetTransferKey), transfer)?.apply() + } + + actual fun loadIosHardwareDecoderMode(): String? = + preferences?.getString(ProfileScopedKey.of(iosHardwareDecoderModeKey), null) + + actual fun saveIosHardwareDecoderMode(mode: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosHardwareDecoderModeKey), mode)?.apply() + } + + private fun loadIosBoolean(keyBase: String, defaultValue: Boolean): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(keyBase) + if (sharedPreferences.contains(key)) sharedPreferences.getBoolean(key, defaultValue) else null + } + + private fun saveIosBoolean(keyBase: String, enabled: Boolean) { + preferences?.edit()?.putBoolean(ProfileScopedKey.of(keyBase), enabled)?.apply() + } + + private fun loadIosInt(keyBase: String): Int? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(keyBase) + if (sharedPreferences.contains(key)) sharedPreferences.getInt(key, 0) else null + } + + private fun saveIosInt(keyBase: String, value: Int) { + preferences?.edit()?.putInt(ProfileScopedKey.of(keyBase), value)?.apply() + } + + actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? = + loadIosBoolean(iosExtendedDynamicRangeEnabledKey, true) + + actual fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) { + saveIosBoolean(iosExtendedDynamicRangeEnabledKey, enabled) + } + + actual fun loadIosTargetColorspaceHintEnabled(): Boolean? = + loadIosBoolean(iosTargetColorspaceHintEnabledKey, true) + + actual fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) { + saveIosBoolean(iosTargetColorspaceHintEnabledKey, enabled) + } + + actual fun loadIosHdrComputePeakEnabled(): Boolean? = + loadIosBoolean(iosHdrComputePeakEnabledKey, true) + + actual fun saveIosHdrComputePeakEnabled(enabled: Boolean) { + saveIosBoolean(iosHdrComputePeakEnabledKey, enabled) + } + + actual fun loadIosDebandEnabled(): Boolean? = + loadIosBoolean(iosDebandEnabledKey, false) + + actual fun saveIosDebandEnabled(enabled: Boolean) { + saveIosBoolean(iosDebandEnabledKey, enabled) + } + + actual fun loadIosInterpolationEnabled(): Boolean? = + loadIosBoolean(iosInterpolationEnabledKey, false) + + actual fun saveIosInterpolationEnabled(enabled: Boolean) { + saveIosBoolean(iosInterpolationEnabledKey, enabled) + } + + actual fun loadIosBrightness(): Int? = loadIosInt(iosBrightnessKey) + + actual fun saveIosBrightness(value: Int) { + saveIosInt(iosBrightnessKey, value) + } + + actual fun loadIosContrast(): Int? = loadIosInt(iosContrastKey) + + actual fun saveIosContrast(value: Int) { + saveIosInt(iosContrastKey, value) + } + + actual fun loadIosSaturation(): Int? = loadIosInt(iosSaturationKey) + + actual fun saveIosSaturation(value: Int) { + saveIosInt(iosSaturationKey, value) + } + + actual fun loadIosGamma(): Int? = loadIosInt(iosGammaKey) + + actual fun saveIosGamma(value: Int) { + saveIosInt(iosGammaKey, value) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } @@ -688,6 +830,20 @@ actual object PlayerSettingsStorage { loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } + loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) } + loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) } + loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } + loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } + loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } + loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } + loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } + loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } + loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) } + loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) } + loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) } + loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) } + loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) } + loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -732,5 +888,19 @@ actual object PlayerSettingsStorage { payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) + payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset) + payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode) + payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) + payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) + payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) + payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) + payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) + payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) + payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled) + payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled) + payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness) + payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast) + payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation) + payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt new file mode 100644 index 00000000..adb17d13 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt @@ -0,0 +1,317 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt + +@Composable +internal fun IosVideoSettingsModal( + visible: Boolean, + settings: PlayerSettingsUiState, + onSettingsChanged: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val colorScheme = MaterialTheme.colorScheme + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(200)), + ) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onDismiss, + ) + .background(colorScheme.scrim.copy(alpha = 0.56f)), + contentAlignment = Alignment.Center, + ) { + val maxH = maxHeight + AnimatedVisibility( + visible = visible, + enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), + exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + ) { + Column( + modifier = Modifier + .widthIn(max = 460.dp) + .fillMaxWidth(0.92f) + .heightIn(max = maxH * 0.95f) + .clip(RoundedCornerShape(24.dp)) + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = {}, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Video", + color = colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { + PlayerSettingsRepository.resetIosVideoOutputTuning() + onSettingsChanged() + }) { + Text("Reset tuning") + } + } + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + OptionGroup( + title = "Output preset", + options = IosVideoOutputPreset.entries, + selected = settings.iosVideoOutputPreset, + label = { it.label }, + description = { it.description }, + onSelect = { + PlayerSettingsRepository.setIosVideoOutputPreset(it) + onSettingsChanged() + }, + ) + + ToggleRow( + title = "HDR peak detection", + description = "Estimate HDR peak brightness when metadata is bad or missing.", + checked = settings.iosHdrComputePeakEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) + onSettingsChanged() + }, + ) + + OptionGroup( + title = "Tone mapping", + options = IosToneMappingMode.entries, + selected = settings.iosToneMappingMode, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosToneMappingMode(it) + onSettingsChanged() + }, + ) + + ToggleRow( + title = "Deband", + description = "Reduce color banding at a small performance cost.", + checked = settings.iosDebandEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosDebandEnabled(it) + onSettingsChanged() + }, + ) + ToggleRow( + title = "Frame interpolation", + description = "Smooth motion when mpv can use display sync cleanly.", + checked = settings.iosInterpolationEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosInterpolationEnabled(it) + onSettingsChanged() + }, + ) + + PictureSlider( + title = "Brightness", + value = settings.iosBrightness, + onValueChanged = { + PlayerSettingsRepository.setIosBrightness(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = "Contrast", + value = settings.iosContrast, + onValueChanged = { + PlayerSettingsRepository.setIosContrast(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = "Saturation", + value = settings.iosSaturation, + onValueChanged = { + PlayerSettingsRepository.setIosSaturation(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = "Gamma", + value = settings.iosGamma, + onValueChanged = { + PlayerSettingsRepository.setIosGamma(it) + onSettingsChanged() + }, + ) + } + } + } + } + } +} + +@Composable +private fun ToggleRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text(text = title, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold) + Text(text = description, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun PictureSlider( + title: String, + value: Int, + onValueChanged: (Int) -> Unit, +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text(text = value.toString(), color = MaterialTheme.colorScheme.primary) + } + Slider( + value = value.toFloat(), + onValueChange = { onValueChanged(it.roundToInt().coerceIn(-50, 50)) }, + valueRange = -50f..50f, + steps = 99, + ) + } +} + +@Composable +private fun OptionGroup( + title: String, + options: List, + selected: T, + label: (T) -> String, + description: ((T) -> String)? = null, + onSelect: (T) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + options.forEach { option -> + val isSelected = option == selected + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(option) }, + color = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + }, + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label(option), color = MaterialTheme.colorScheme.onSurface) + val subtitle = description?.invoke(option) + if (!subtitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + } + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 540ed57b..4f6fff95 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.Replay10 import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.VideoLibrary import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -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, @@ -182,6 +184,7 @@ internal fun PlayerControlsShell( onSpeedClick = onSpeedClick, onSubtitleClick = onSubtitleClick, onAudioClick = onAudioClick, + onVideoSettingsClick = onVideoSettingsClick, onSourcesClick = onSourcesClick, onEpisodesClick = onEpisodesClick, modifier = Modifier @@ -469,6 +472,7 @@ private fun ProgressControls( onSpeedClick: () -> Unit, onSubtitleClick: () -> Unit, onAudioClick: () -> Unit, + onVideoSettingsClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, modifier: Modifier = Modifier, @@ -538,6 +542,13 @@ private fun ProgressControls( painter = audioPainter, onClick = onAudioClick, ) + if (onVideoSettingsClick != null) { + PlayerActionPillButton( + label = "Video", + icon = Icons.Rounded.Tune, + onClick = onVideoSettingsClick, + ) + } if (onSourcesClick != null) { PlayerActionPillButton( label = stringResource(Res.string.compose_player_sources), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 8a5b6730..ac0be69f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -18,6 +18,7 @@ interface PlayerEngineController { fun clearExternalSubtitle() fun clearExternalSubtitleAndSelect(trackIndex: Int) fun applySubtitleStyle(style: SubtitleStyleState) {} + fun configureIosVideoOutput(settings: PlayerSettingsUiState) {} } internal fun sanitizePlaybackHeaders(headers: Map?): Map { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index f659084a..773a276d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -62,6 +62,73 @@ enum class PlayerResizeMode { Zoom, } +enum class IosVideoOutputPreset( + val label: String, + val description: String, +) { + NativeEdr( + label = "Native EDR", + description = "Best for HDR-capable iPhones and iPads.", + ), + SdrToneMapped( + label = "SDR tone mapped", + description = "More predictable whites and blacks on SDR-style output.", + ), + Compatibility( + label = "Compatibility", + description = "Closest to the older iOS MPV behavior.", + ), + Custom( + label = "Custom", + description = "Use your advanced values below.", + ), +} + +enum class IosToneMappingMode( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + Bt2390("bt.2390", "BT.2390"), + Mobius("mobius", "Mobius"), + Reinhard("reinhard", "Reinhard"), + Hable("hable", "Hable"), + Gamma("gamma", "Gamma"), + Clip("clip", "Clip"), +} + +enum class IosTargetPrimaries( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + Bt709("bt.709", "BT.709"), + DisplayP3("display-p3", "Display P3"), + Bt2020("bt.2020", "BT.2020"), +} + +enum class IosTargetTransfer( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + Srgb("srgb", "sRGB"), + Bt1886("bt.1886", "BT.1886"), + Gamma22("gamma2.2", "Gamma 2.2"), + Gamma24("gamma2.4", "Gamma 2.4"), + Pq("pq", "PQ"), + Hlg("hlg", "HLG"), +} + +enum class IosHardwareDecoderMode( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + VideoToolbox("videotoolbox", "VideoToolbox"), + Off("no", "Off"), +} + data class PlayerPlaybackSnapshot( val isLoading: Boolean = true, val isPlaying: Boolean = false, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 34ac6e92..476b0a77 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -68,6 +68,7 @@ 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 @@ -472,6 +473,7 @@ fun PlayerScreen( var showAudioModal by remember { mutableStateOf(false) } var showSubtitleModal by remember { mutableStateOf(false) } + var showVideoSettingsModal by remember { mutableStateOf(false) } var audioTracks by remember { mutableStateOf>(emptyList()) } var subtitleTracks by remember { mutableStateOf>(emptyList()) } var selectedAudioIndex by remember { mutableStateOf(-1) } @@ -609,6 +611,7 @@ fun PlayerScreen( renderedGestureFeedback = null showAudioModal = false showSubtitleModal = false + showVideoSettingsModal = false showSourcesPanel = false showEpisodesPanel = false episodeStreamsPanelState = EpisodeStreamsPanelState() @@ -1805,6 +1808,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, @@ -1973,6 +1984,15 @@ fun PlayerScreen( onDismiss = { showSubtitleModal = false }, ) + IosVideoSettingsModal( + visible = showVideoSettingsModal, + settings = playerSettingsUiState, + onSettingsChanged = { + playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value) + }, + onDismiss = { showVideoSettingsModal = false }, + ) + // Sources Panel PlayerSourcesPanel( visible = showSourcesPanel, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index 15f4f4d7..1c5cd8c7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -43,6 +43,20 @@ data class PlayerSettingsUiState( 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 { @@ -84,6 +98,20 @@ object PlayerSettingsRepository { 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 @@ -130,6 +158,20 @@ object PlayerSettingsRepository { 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() } @@ -204,6 +246,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() } @@ -498,6 +564,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, @@ -534,6 +758,20 @@ object PlayerSettingsRepository { nextEpisodeThresholdMinutesBeforeEnd = nextEpisodeThresholdMinutesBeforeEnd, useLibass = useLibass, libassRenderType = libassRenderType, + iosVideoOutputPreset = iosVideoOutputPreset, + iosToneMappingMode = iosToneMappingMode, + iosTargetPrimaries = iosTargetPrimaries, + iosTargetTransfer = iosTargetTransfer, + iosHardwareDecoderMode = iosHardwareDecoderMode, + iosExtendedDynamicRangeEnabled = iosExtendedDynamicRangeEnabled, + iosTargetColorspaceHintEnabled = iosTargetColorspaceHintEnabled, + iosHdrComputePeakEnabled = iosHdrComputePeakEnabled, + iosDebandEnabled = iosDebandEnabled, + iosInterpolationEnabled = iosInterpolationEnabled, + iosBrightness = iosBrightness, + iosContrast = iosContrast, + iosSaturation = iosSaturation, + iosGamma = iosGamma, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 5c3b3756..d36cd301 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -78,6 +78,34 @@ internal expect object PlayerSettingsStorage { fun saveUseLibass(enabled: Boolean) fun loadLibassRenderType(): String? fun saveLibassRenderType(renderType: String) + fun loadIosVideoOutputPreset(): String? + fun saveIosVideoOutputPreset(preset: String) + fun loadIosToneMappingMode(): String? + fun saveIosToneMappingMode(mode: String) + fun loadIosTargetPrimaries(): String? + fun saveIosTargetPrimaries(primaries: String) + fun loadIosTargetTransfer(): String? + fun saveIosTargetTransfer(transfer: String) + fun loadIosHardwareDecoderMode(): String? + fun saveIosHardwareDecoderMode(mode: String) + fun loadIosExtendedDynamicRangeEnabled(): Boolean? + fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) + fun loadIosTargetColorspaceHintEnabled(): Boolean? + fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) + fun loadIosHdrComputePeakEnabled(): Boolean? + fun saveIosHdrComputePeakEnabled(enabled: Boolean) + fun loadIosDebandEnabled(): Boolean? + fun saveIosDebandEnabled(enabled: Boolean) + fun loadIosInterpolationEnabled(): Boolean? + fun saveIosInterpolationEnabled(enabled: Boolean) + fun loadIosBrightness(): Int? + fun saveIosBrightness(value: Int) + fun loadIosContrast(): Int? + fun saveIosContrast(value: Int) + fun loadIosSaturation(): Int? + fun saveIosSaturation(value: Int) + fun loadIosGamma(): Int? + fun saveIosGamma(value: Int) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 042d592d..83daa6df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -55,6 +55,9 @@ 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.SubtitleLanguageOption import com.nuvio.app.features.player.formatPlaybackSpeedLabel @@ -175,6 +178,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) } @@ -487,6 +493,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), @@ -854,6 +906,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 +1412,94 @@ private fun DecoderPriorityDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun IosEnumSelectionDialog( + title: String, + options: List, + selected: T, + label: (T) -> String, + onSelect: (T) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { option -> + val isSelected = option == selected + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(option) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label(option), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun HoldToSpeedValueDialog( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 4c627dc2..9012a96c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -15,6 +15,21 @@ interface NuvioPlayerBridge { fun seekTo(positionMs: Long) fun seekBy(offsetMs: Long) fun retry() + fun configureVideoOutput( + hardwareDecoder: String, + targetColorspaceHint: Boolean, + toneMapping: String, + hdrComputePeak: Boolean, + targetPrimaries: String, + targetTransfer: String, + extendedDynamicRange: Boolean, + deband: Boolean, + interpolation: Boolean, + brightness: Int, + contrast: Int, + saturation: Int, + gamma: Int, + ) fun setPlaybackSpeed(speed: Float) fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom fun getAudioTrackCount(): Int diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 2877b04c..733bf162 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -3,11 +3,13 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitViewController +import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.delay @@ -37,6 +39,9 @@ actual fun PlatformPlayerSurface( val latestOnControllerReady = rememberUpdatedState(onControllerReady) val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnError = rememberUpdatedState(onError) + PlayerSettingsRepository.ensureLoaded() + val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() + val latestPlayerSettings = rememberUpdatedState(playerSettings) val bridge = remember { NuvioPlayerBridgeFactory.create() @@ -71,6 +76,10 @@ actual fun PlatformPlayerSurface( bridge.retry() } + override fun configureIosVideoOutput(settings: PlayerSettingsUiState) { + bridge.applyIosVideoOutputSettings(settings) + } + override fun setPlaybackSpeed(speed: Float) { bridge.setPlaybackSpeed(speed) } @@ -214,6 +223,7 @@ actual fun PlatformPlayerSurface( // Load file and set initial state LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) { + bridge.applyIosVideoOutputSettings(latestPlayerSettings.value) bridge.loadFileWithAudio( sourceUrl, sourceAudioUrl, @@ -242,6 +252,10 @@ actual fun PlatformPlayerSurface( ) } + LaunchedEffect(bridge, playerSettings) { + bridge.applyIosVideoOutputSettings(playerSettings) + } + // Polling for snapshots LaunchedEffect(bridge) { var lastReportedError: String? = null @@ -280,6 +294,24 @@ actual fun PlatformPlayerSurface( ) } +private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) { + configureVideoOutput( + hardwareDecoder = settings.iosHardwareDecoderMode.mpvValue, + targetColorspaceHint = settings.iosTargetColorspaceHintEnabled, + toneMapping = settings.iosToneMappingMode.mpvValue, + hdrComputePeak = settings.iosHdrComputePeakEnabled, + targetPrimaries = settings.iosTargetPrimaries.mpvValue, + targetTransfer = settings.iosTargetTransfer.mpvValue, + extendedDynamicRange = settings.iosExtendedDynamicRangeEnabled, + deband = settings.iosDebandEnabled, + interpolation = settings.iosInterpolationEnabled, + brightness = settings.iosBrightness, + contrast = settings.iosContrast, + saturation = settings.iosSaturation, + gamma = settings.iosGamma, + ) +} + private fun Color.toMpvColorString(): String { val redInt = (red * 255f).toInt().coerceIn(0, 255) val greenInt = (green * 255f).toInt().coerceIn(0, 255) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index 0aedbb30..9e539f5d 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -54,6 +54,20 @@ actual object PlayerSettingsStorage { private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" private const val useLibassKey = "use_libass" private const val libassRenderTypeKey = "libass_render_type" + private const val iosVideoOutputPresetKey = "ios_video_output_preset" + private const val iosToneMappingModeKey = "ios_tone_mapping_mode" + private const val iosTargetPrimariesKey = "ios_target_primaries" + private const val iosTargetTransferKey = "ios_target_transfer" + private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" + private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" + private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" + private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" + private const val iosDebandEnabledKey = "ios_deband_enabled" + private const val iosInterpolationEnabledKey = "ios_interpolation_enabled" + private const val iosBrightnessKey = "ios_brightness" + private const val iosContrastKey = "ios_contrast" + private const val iosSaturationKey = "ios_saturation" + private const val iosGammaKey = "ios_gamma" private val syncKeys = listOf( showLoadingOverlayKey, resizeModeKey, @@ -90,8 +104,42 @@ actual object PlayerSettingsStorage { 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) @@ -552,6 +600,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)) } @@ -588,6 +730,20 @@ actual object PlayerSettingsStorage { loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } + loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) } + loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) } + loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } + loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } + loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } + loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } + loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } + loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } + loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) } + loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) } + loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) } + loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) } + loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) } + loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -631,5 +787,19 @@ actual object PlayerSettingsStorage { payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) + payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset) + payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode) + payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) + payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) + payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) + payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) + payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) + payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) + payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled) + payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled) + payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness) + payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast) + payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation) + payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma) } } diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 39ec6e2b..afcdc601 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -28,6 +28,37 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) } func retry() { playerVC?.retryPlayback() } + func configureVideoOutput( + hardwareDecoder: String, + targetColorspaceHint: Bool, + toneMapping: String, + hdrComputePeak: Bool, + targetPrimaries: String, + targetTransfer: String, + extendedDynamicRange: Bool, + deband: Bool, + interpolation: Bool, + brightness: Int32, + contrast: Int32, + saturation: Int32, + gamma: Int32 + ) { + playerVC?.configureVideoOutput( + hardwareDecoder: hardwareDecoder, + targetColorspaceHint: targetColorspaceHint, + toneMapping: toneMapping, + hdrComputePeak: hdrComputePeak, + targetPrimaries: targetPrimaries, + targetTransfer: targetTransfer, + extendedDynamicRange: extendedDynamicRange, + deband: deband, + interpolation: interpolation, + brightness: Int(brightness), + contrast: Int(contrast), + saturation: Int(saturation), + gamma: Int(gamma) + ) + } func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } @@ -204,6 +235,7 @@ final class MPVPlayerViewController: UIViewController { metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale metalLayer.framebufferOnly = true metalLayer.backgroundColor = UIColor.black.cgColor + metalLayer.wantsExtendedDynamicRangeContent = true view.layer.addSublayer(metalLayer) layoutMetalLayer() @@ -286,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)) @@ -435,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) @@ -831,6 +895,12 @@ final class MPVPlayerViewController: UIViewController { 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()