feat(ios): adding custom tuning for mpv

This commit is contained in:
tapframe 2026-05-19 21:04:39 +05:30
parent a58f172010
commit 873f81a954
13 changed files with 1322 additions and 1 deletions

View file

@ -56,6 +56,20 @@ actual object PlayerSettingsStorage {
private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2"
private const val useLibassKey = "use_libass" private const val useLibassKey = "use_libass"
private const val libassRenderTypeKey = "libass_render_type" 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( private val syncKeys = listOf(
showLoadingOverlayKey, showLoadingOverlayKey,
resizeModeKey, resizeModeKey,
@ -92,6 +106,20 @@ actual object PlayerSettingsStorage {
nextEpisodeThresholdMinutesBeforeEndKey, nextEpisodeThresholdMinutesBeforeEndKey,
useLibassKey, useLibassKey,
libassRenderTypeKey, libassRenderTypeKey,
iosVideoOutputPresetKey,
iosToneMappingModeKey,
iosTargetPrimariesKey,
iosTargetTransferKey,
iosHardwareDecoderModeKey,
iosExtendedDynamicRangeEnabledKey,
iosTargetColorspaceHintEnabledKey,
iosHdrComputePeakEnabledKey,
iosDebandEnabledKey,
iosInterpolationEnabledKey,
iosBrightnessKey,
iosContrastKey,
iosSaturationKey,
iosGammaKey,
) )
private var preferences: SharedPreferences? = null private var preferences: SharedPreferences? = null
@ -652,6 +680,120 @@ actual object PlayerSettingsStorage {
?.apply() ?.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 { actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) }
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
@ -688,6 +830,20 @@ actual object PlayerSettingsStorage {
loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) }
loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) }
loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(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) { actual fun replaceFromSyncPayload(payload: JsonObject) {
@ -732,5 +888,19 @@ actual object PlayerSettingsStorage {
payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd)
payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass)
payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType)
payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset)
payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode)
payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries)
payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer)
payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode)
payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled)
payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled)
payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled)
payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled)
payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled)
payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness)
payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast)
payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation)
payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma)
} }
} }

View file

@ -0,0 +1,317 @@
package com.nuvio.app.features.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
@Composable
internal fun IosVideoSettingsModal(
visible: Boolean,
settings: PlayerSettingsUiState,
onSettingsChanged: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val colorScheme = MaterialTheme.colorScheme
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(200)),
) {
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onDismiss,
)
.background(colorScheme.scrim.copy(alpha = 0.56f)),
contentAlignment = Alignment.Center,
) {
val maxH = maxHeight
AnimatedVisibility(
visible = visible,
enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)),
exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)),
) {
Column(
modifier = Modifier
.widthIn(max = 460.dp)
.fillMaxWidth(0.92f)
.heightIn(max = maxH * 0.95f)
.clip(RoundedCornerShape(24.dp))
.background(colorScheme.surface)
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Video",
color = colorScheme.onSurface,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = {
PlayerSettingsRepository.resetIosVideoOutputTuning()
onSettingsChanged()
}) {
Text("Reset tuning")
}
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
OptionGroup(
title = "Output preset",
options = IosVideoOutputPreset.entries,
selected = settings.iosVideoOutputPreset,
label = { it.label },
description = { it.description },
onSelect = {
PlayerSettingsRepository.setIosVideoOutputPreset(it)
onSettingsChanged()
},
)
ToggleRow(
title = "HDR peak detection",
description = "Estimate HDR peak brightness when metadata is bad or missing.",
checked = settings.iosHdrComputePeakEnabled,
onCheckedChange = {
PlayerSettingsRepository.setIosHdrComputePeakEnabled(it)
onSettingsChanged()
},
)
OptionGroup(
title = "Tone mapping",
options = IosToneMappingMode.entries,
selected = settings.iosToneMappingMode,
label = { it.label },
onSelect = {
PlayerSettingsRepository.setIosToneMappingMode(it)
onSettingsChanged()
},
)
ToggleRow(
title = "Deband",
description = "Reduce color banding at a small performance cost.",
checked = settings.iosDebandEnabled,
onCheckedChange = {
PlayerSettingsRepository.setIosDebandEnabled(it)
onSettingsChanged()
},
)
ToggleRow(
title = "Frame interpolation",
description = "Smooth motion when mpv can use display sync cleanly.",
checked = settings.iosInterpolationEnabled,
onCheckedChange = {
PlayerSettingsRepository.setIosInterpolationEnabled(it)
onSettingsChanged()
},
)
PictureSlider(
title = "Brightness",
value = settings.iosBrightness,
onValueChanged = {
PlayerSettingsRepository.setIosBrightness(it)
onSettingsChanged()
},
)
PictureSlider(
title = "Contrast",
value = settings.iosContrast,
onValueChanged = {
PlayerSettingsRepository.setIosContrast(it)
onSettingsChanged()
},
)
PictureSlider(
title = "Saturation",
value = settings.iosSaturation,
onValueChanged = {
PlayerSettingsRepository.setIosSaturation(it)
onSettingsChanged()
},
)
PictureSlider(
title = "Gamma",
value = settings.iosGamma,
onValueChanged = {
PlayerSettingsRepository.setIosGamma(it)
onSettingsChanged()
},
)
}
}
}
}
}
}
@Composable
private fun ToggleRow(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold)
Text(text = description, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun PictureSlider(
title: String,
value: Int,
onValueChanged: (Int) -> Unit,
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Text(text = value.toString(), color = MaterialTheme.colorScheme.primary)
}
Slider(
value = value.toFloat(),
onValueChange = { onValueChanged(it.roundToInt().coerceIn(-50, 50)) },
valueRange = -50f..50f,
steps = 99,
)
}
}
@Composable
private fun <T> OptionGroup(
title: String,
options: List<T>,
selected: T,
label: (T) -> String,
description: ((T) -> String)? = null,
onSelect: (T) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = title,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
options.forEach { option ->
val isSelected = option == selected
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(option) },
color = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
},
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = label(option), color = MaterialTheme.colorScheme.onSurface)
val subtitle = description?.invoke(option)
if (!subtitle.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
}
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}

View file

@ -31,6 +31,7 @@ import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.Replay10 import androidx.compose.material.icons.rounded.Replay10
import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.Speed
import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material.icons.rounded.VideoLibrary import androidx.compose.material.icons.rounded.VideoLibrary
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -83,6 +84,7 @@ internal fun PlayerControlsShell(
onSpeedClick: () -> Unit, onSpeedClick: () -> Unit,
onSubtitleClick: () -> Unit, onSubtitleClick: () -> Unit,
onAudioClick: () -> Unit, onAudioClick: () -> Unit,
onVideoSettingsClick: (() -> Unit)? = null,
onSourcesClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null,
onEpisodesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null,
onSubmitIntroClick: (() -> Unit)? = null, onSubmitIntroClick: (() -> Unit)? = null,
@ -182,6 +184,7 @@ internal fun PlayerControlsShell(
onSpeedClick = onSpeedClick, onSpeedClick = onSpeedClick,
onSubtitleClick = onSubtitleClick, onSubtitleClick = onSubtitleClick,
onAudioClick = onAudioClick, onAudioClick = onAudioClick,
onVideoSettingsClick = onVideoSettingsClick,
onSourcesClick = onSourcesClick, onSourcesClick = onSourcesClick,
onEpisodesClick = onEpisodesClick, onEpisodesClick = onEpisodesClick,
modifier = Modifier modifier = Modifier
@ -469,6 +472,7 @@ private fun ProgressControls(
onSpeedClick: () -> Unit, onSpeedClick: () -> Unit,
onSubtitleClick: () -> Unit, onSubtitleClick: () -> Unit,
onAudioClick: () -> Unit, onAudioClick: () -> Unit,
onVideoSettingsClick: (() -> Unit)? = null,
onSourcesClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null,
onEpisodesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -538,6 +542,13 @@ private fun ProgressControls(
painter = audioPainter, painter = audioPainter,
onClick = onAudioClick, onClick = onAudioClick,
) )
if (onVideoSettingsClick != null) {
PlayerActionPillButton(
label = "Video",
icon = Icons.Rounded.Tune,
onClick = onVideoSettingsClick,
)
}
if (onSourcesClick != null) { if (onSourcesClick != null) {
PlayerActionPillButton( PlayerActionPillButton(
label = stringResource(Res.string.compose_player_sources), label = stringResource(Res.string.compose_player_sources),

View file

@ -18,6 +18,7 @@ interface PlayerEngineController {
fun clearExternalSubtitle() fun clearExternalSubtitle()
fun clearExternalSubtitleAndSelect(trackIndex: Int) fun clearExternalSubtitleAndSelect(trackIndex: Int)
fun applySubtitleStyle(style: SubtitleStyleState) {} fun applySubtitleStyle(style: SubtitleStyleState) {}
fun configureIosVideoOutput(settings: PlayerSettingsUiState) {}
} }
internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String, String> { internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String, String> {

View file

@ -62,6 +62,73 @@ enum class PlayerResizeMode {
Zoom, 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( data class PlayerPlaybackSnapshot(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isPlaying: Boolean = false, val isPlaying: Boolean = false,

View file

@ -68,6 +68,7 @@ import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.isIos
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -472,6 +473,7 @@ fun PlayerScreen(
var showAudioModal by remember { mutableStateOf(false) } var showAudioModal by remember { mutableStateOf(false) }
var showSubtitleModal 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 audioTracks by remember { mutableStateOf<List<AudioTrack>>(emptyList()) }
var subtitleTracks by remember { mutableStateOf<List<SubtitleTrack>>(emptyList()) } var subtitleTracks by remember { mutableStateOf<List<SubtitleTrack>>(emptyList()) }
var selectedAudioIndex by remember { mutableStateOf(-1) } var selectedAudioIndex by remember { mutableStateOf(-1) }
@ -609,6 +611,7 @@ fun PlayerScreen(
renderedGestureFeedback = null renderedGestureFeedback = null
showAudioModal = false showAudioModal = false
showSubtitleModal = false showSubtitleModal = false
showVideoSettingsModal = false
showSourcesPanel = false showSourcesPanel = false
showEpisodesPanel = false showEpisodesPanel = false
episodeStreamsPanelState = EpisodeStreamsPanelState() episodeStreamsPanelState = EpisodeStreamsPanelState()
@ -1805,6 +1808,14 @@ fun PlayerScreen(
refreshTracks() refreshTracks()
showAudioModal = true showAudioModal = true
}, },
onVideoSettingsClick = if (isIos) {
{
showVideoSettingsModal = true
controlsVisible = true
}
} else {
null
},
onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null, onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null,
@ -1973,6 +1984,15 @@ fun PlayerScreen(
onDismiss = { showSubtitleModal = false }, onDismiss = { showSubtitleModal = false },
) )
IosVideoSettingsModal(
visible = showVideoSettingsModal,
settings = playerSettingsUiState,
onSettingsChanged = {
playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value)
},
onDismiss = { showVideoSettingsModal = false },
)
// Sources Panel // Sources Panel
PlayerSourcesPanel( PlayerSourcesPanel(
visible = showSourcesPanel, visible = showSourcesPanel,

View file

@ -43,6 +43,20 @@ data class PlayerSettingsUiState(
val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f, val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f,
val useLibass: Boolean = false, val useLibass: Boolean = false,
val libassRenderType: String = "CUES", 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 { object PlayerSettingsRepository {
@ -84,6 +98,20 @@ object PlayerSettingsRepository {
private var nextEpisodeThresholdMinutesBeforeEnd = 2f private var nextEpisodeThresholdMinutesBeforeEnd = 2f
private var useLibass = false private var useLibass = false
private var libassRenderType = "CUES" 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() { fun ensureLoaded() {
if (hasLoaded) return if (hasLoaded) return
@ -130,6 +158,20 @@ object PlayerSettingsRepository {
nextEpisodeThresholdMinutesBeforeEnd = 2f nextEpisodeThresholdMinutesBeforeEnd = 2f
useLibass = false useLibass = false
libassRenderType = "CUES" 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() publish()
} }
@ -204,6 +246,30 @@ object PlayerSettingsRepository {
nextEpisodeThresholdMinutesBeforeEnd = PlayerSettingsStorage.loadNextEpisodeThresholdMinutesBeforeEnd() ?: 2f nextEpisodeThresholdMinutesBeforeEnd = PlayerSettingsStorage.loadNextEpisodeThresholdMinutesBeforeEnd() ?: 2f
useLibass = PlayerSettingsStorage.loadUseLibass() ?: false useLibass = PlayerSettingsStorage.loadUseLibass() ?: false
libassRenderType = PlayerSettingsStorage.loadLibassRenderType() ?: "CUES" 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() publish()
} }
@ -498,6 +564,164 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveLibassRenderType(renderType) 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() { private fun publish() {
_uiState.value = PlayerSettingsUiState( _uiState.value = PlayerSettingsUiState(
showLoadingOverlay = showLoadingOverlay, showLoadingOverlay = showLoadingOverlay,
@ -534,6 +758,20 @@ object PlayerSettingsRepository {
nextEpisodeThresholdMinutesBeforeEnd = nextEpisodeThresholdMinutesBeforeEnd, nextEpisodeThresholdMinutesBeforeEnd = nextEpisodeThresholdMinutesBeforeEnd,
useLibass = useLibass, useLibass = useLibass,
libassRenderType = libassRenderType, libassRenderType = libassRenderType,
iosVideoOutputPreset = iosVideoOutputPreset,
iosToneMappingMode = iosToneMappingMode,
iosTargetPrimaries = iosTargetPrimaries,
iosTargetTransfer = iosTargetTransfer,
iosHardwareDecoderMode = iosHardwareDecoderMode,
iosExtendedDynamicRangeEnabled = iosExtendedDynamicRangeEnabled,
iosTargetColorspaceHintEnabled = iosTargetColorspaceHintEnabled,
iosHdrComputePeakEnabled = iosHdrComputePeakEnabled,
iosDebandEnabled = iosDebandEnabled,
iosInterpolationEnabled = iosInterpolationEnabled,
iosBrightness = iosBrightness,
iosContrast = iosContrast,
iosSaturation = iosSaturation,
iosGamma = iosGamma,
) )
} }

View file

@ -78,6 +78,34 @@ internal expect object PlayerSettingsStorage {
fun saveUseLibass(enabled: Boolean) fun saveUseLibass(enabled: Boolean)
fun loadLibassRenderType(): String? fun loadLibassRenderType(): String?
fun saveLibassRenderType(renderType: 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 exportToSyncPayload(): JsonObject
fun replaceFromSyncPayload(payload: JsonObject) fun replaceFromSyncPayload(payload: JsonObject)
} }

View file

@ -55,6 +55,9 @@ import com.nuvio.app.features.player.AudioLanguageOption
import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.AvailableLanguageOptions
import com.nuvio.app.features.player.ExternalPlayerApp import com.nuvio.app.features.player.ExternalPlayerApp
import com.nuvio.app.features.player.ExternalPlayerPlatform 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.PlayerSettingsRepository
import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.SubtitleLanguageOption
import com.nuvio.app.features.player.formatPlaybackSpeedLabel import com.nuvio.app.features.player.formatPlaybackSpeedLabel
@ -175,6 +178,9 @@ private fun PlaybackSettingsSection(
var showReuseCacheDurationDialog by remember { mutableStateOf(false) } var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
var showDecoderPriorityDialog by remember { mutableStateOf(false) } var showDecoderPriorityDialog by remember { mutableStateOf(false) }
var showHoldToSpeedValueDialog 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 showLibassRenderTypeDialog by remember { mutableStateOf(false) }
var showAutoPlayModeDialog by remember { mutableStateOf(false) } var showAutoPlayModeDialog by remember { mutableStateOf(false) }
var showAutoPlaySourceDialog 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) { if (!isIos) {
SettingsSection( SettingsSection(
title = stringResource(Res.string.settings_playback_section_subtitle_rendering), 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) { if (showLibassRenderTypeDialog) {
LibassRenderTypeDialog( LibassRenderTypeDialog(
selectedRenderType = libassRenderType, selectedRenderType = libassRenderType,
@ -1318,6 +1412,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 @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
private fun HoldToSpeedValueDialog( private fun HoldToSpeedValueDialog(

View file

@ -15,6 +15,21 @@ interface NuvioPlayerBridge {
fun seekTo(positionMs: Long) fun seekTo(positionMs: Long)
fun seekBy(offsetMs: Long) fun seekBy(offsetMs: Long)
fun retry() 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 setPlaybackSpeed(speed: Float)
fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom
fun getAudioTrackCount(): Int fun getAudioTrackCount(): Int

View file

@ -3,11 +3,13 @@ package com.nuvio.app.features.player
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitViewController import androidx.compose.ui.interop.UIKitViewController
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -37,6 +39,9 @@ actual fun PlatformPlayerSurface(
val latestOnControllerReady = rememberUpdatedState(onControllerReady) val latestOnControllerReady = rememberUpdatedState(onControllerReady)
val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnSnapshot = rememberUpdatedState(onSnapshot)
val latestOnError = rememberUpdatedState(onError) val latestOnError = rememberUpdatedState(onError)
PlayerSettingsRepository.ensureLoaded()
val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
val latestPlayerSettings = rememberUpdatedState(playerSettings)
val bridge = remember { val bridge = remember {
NuvioPlayerBridgeFactory.create() NuvioPlayerBridgeFactory.create()
@ -71,6 +76,10 @@ actual fun PlatformPlayerSurface(
bridge.retry() bridge.retry()
} }
override fun configureIosVideoOutput(settings: PlayerSettingsUiState) {
bridge.applyIosVideoOutputSettings(settings)
}
override fun setPlaybackSpeed(speed: Float) { override fun setPlaybackSpeed(speed: Float) {
bridge.setPlaybackSpeed(speed) bridge.setPlaybackSpeed(speed)
} }
@ -214,6 +223,7 @@ actual fun PlatformPlayerSurface(
// Load file and set initial state // Load file and set initial state
LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) { LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) {
bridge.applyIosVideoOutputSettings(latestPlayerSettings.value)
bridge.loadFileWithAudio( bridge.loadFileWithAudio(
sourceUrl, sourceUrl,
sourceAudioUrl, sourceAudioUrl,
@ -242,6 +252,10 @@ actual fun PlatformPlayerSurface(
) )
} }
LaunchedEffect(bridge, playerSettings) {
bridge.applyIosVideoOutputSettings(playerSettings)
}
// Polling for snapshots // Polling for snapshots
LaunchedEffect(bridge) { LaunchedEffect(bridge) {
var lastReportedError: String? = null 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 { private fun Color.toMpvColorString(): String {
val redInt = (red * 255f).toInt().coerceIn(0, 255) val redInt = (red * 255f).toInt().coerceIn(0, 255)
val greenInt = (green * 255f).toInt().coerceIn(0, 255) val greenInt = (green * 255f).toInt().coerceIn(0, 255)

View file

@ -54,6 +54,20 @@ actual object PlayerSettingsStorage {
private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2"
private const val useLibassKey = "use_libass" private const val useLibassKey = "use_libass"
private const val libassRenderTypeKey = "libass_render_type" 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( private val syncKeys = listOf(
showLoadingOverlayKey, showLoadingOverlayKey,
resizeModeKey, resizeModeKey,
@ -90,8 +104,42 @@ actual object PlayerSettingsStorage {
nextEpisodeThresholdMinutesBeforeEndKey, nextEpisodeThresholdMinutesBeforeEndKey,
useLibassKey, useLibassKey,
libassRenderTypeKey, 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? { actual fun loadShowLoadingOverlay(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(showLoadingOverlayKey) val key = ProfileScopedKey.of(showLoadingOverlayKey)
@ -552,6 +600,100 @@ actual object PlayerSettingsStorage {
actual fun saveLibassRenderType(renderType: String) {} 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 { actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) }
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
@ -588,6 +730,20 @@ actual object PlayerSettingsStorage {
loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) }
loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) }
loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(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) { actual fun replaceFromSyncPayload(payload: JsonObject) {
@ -631,5 +787,19 @@ actual object PlayerSettingsStorage {
payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd)
payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass)
payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType)
payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset)
payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode)
payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries)
payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer)
payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode)
payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled)
payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled)
payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled)
payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled)
payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled)
payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness)
payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast)
payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation)
payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma)
} }
} }

View file

@ -28,6 +28,37 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) } func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) }
func retry() { playerVC?.retryPlayback() } 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 setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) }
func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } 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.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale
metalLayer.framebufferOnly = true metalLayer.framebufferOnly = true
metalLayer.backgroundColor = UIColor.black.cgColor metalLayer.backgroundColor = UIColor.black.cgColor
metalLayer.wantsExtendedDynamicRangeContent = true
view.layer.addSublayer(metalLayer) view.layer.addSublayer(metalLayer)
layoutMetalLayer() layoutMetalLayer()
@ -286,7 +318,7 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "keep-open", "yes")) 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, "target-colorspace-hint", "yes"))
checkError(mpv_set_option_string(mpv, "tone-mapping", "auto")) 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)) 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) { func setSpeed(_ speed: Float) {
guard mpv != nil else { return } guard mpv != nil else { return }
var s = Double(speed) var s = Double(speed)
@ -831,6 +895,12 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_property_string(mpv, name, value)) 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 { private func getInt(_ name: String) -> Int {
guard mpv != nil else { return 0 } guard mpv != nil else { return 0 }
var data = Int64() var data = Int64()