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

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

View file

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

View file

@ -62,6 +62,73 @@ enum class PlayerResizeMode {
Zoom,
}
enum class IosVideoOutputPreset(
val label: String,
val description: String,
) {
NativeEdr(
label = "Native EDR",
description = "Best for HDR-capable iPhones and iPads.",
),
SdrToneMapped(
label = "SDR tone mapped",
description = "More predictable whites and blacks on SDR-style output.",
),
Compatibility(
label = "Compatibility",
description = "Closest to the older iOS MPV behavior.",
),
Custom(
label = "Custom",
description = "Use your advanced values below.",
),
}
enum class IosToneMappingMode(
val mpvValue: String,
val label: String,
) {
Auto("auto", "Auto"),
Bt2390("bt.2390", "BT.2390"),
Mobius("mobius", "Mobius"),
Reinhard("reinhard", "Reinhard"),
Hable("hable", "Hable"),
Gamma("gamma", "Gamma"),
Clip("clip", "Clip"),
}
enum class IosTargetPrimaries(
val mpvValue: String,
val label: String,
) {
Auto("auto", "Auto"),
Bt709("bt.709", "BT.709"),
DisplayP3("display-p3", "Display P3"),
Bt2020("bt.2020", "BT.2020"),
}
enum class IosTargetTransfer(
val mpvValue: String,
val label: String,
) {
Auto("auto", "Auto"),
Srgb("srgb", "sRGB"),
Bt1886("bt.1886", "BT.1886"),
Gamma22("gamma2.2", "Gamma 2.2"),
Gamma24("gamma2.4", "Gamma 2.4"),
Pq("pq", "PQ"),
Hlg("hlg", "HLG"),
}
enum class IosHardwareDecoderMode(
val mpvValue: String,
val label: String,
) {
Auto("auto", "Auto"),
VideoToolbox("videotoolbox", "VideoToolbox"),
Off("no", "Off"),
}
data class PlayerPlaybackSnapshot(
val isLoading: Boolean = true,
val isPlaying: Boolean = false,

View file

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

View file

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

View file

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

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.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 <T> IosEnumSelectionDialog(
title: String,
options: List<T>,
selected: T,
label: (T) -> String,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) {
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { option ->
val isSelected = option == selected
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(option) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label(option),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(Res.string.settings_playback_dialog_close),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun HoldToSpeedValueDialog(

View file

@ -15,6 +15,21 @@ interface NuvioPlayerBridge {
fun seekTo(positionMs: Long)
fun seekBy(offsetMs: Long)
fun retry()
fun configureVideoOutput(
hardwareDecoder: String,
targetColorspaceHint: Boolean,
toneMapping: String,
hdrComputePeak: Boolean,
targetPrimaries: String,
targetTransfer: String,
extendedDynamicRange: Boolean,
deband: Boolean,
interpolation: Boolean,
brightness: Int,
contrast: Int,
saturation: Int,
gamma: Int,
)
fun setPlaybackSpeed(speed: Float)
fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom
fun getAudioTrackCount(): Int

View file

@ -3,11 +3,13 @@ package com.nuvio.app.features.player
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitViewController
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.delay
@ -37,6 +39,9 @@ actual fun PlatformPlayerSurface(
val latestOnControllerReady = rememberUpdatedState(onControllerReady)
val latestOnSnapshot = rememberUpdatedState(onSnapshot)
val latestOnError = rememberUpdatedState(onError)
PlayerSettingsRepository.ensureLoaded()
val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
val latestPlayerSettings = rememberUpdatedState(playerSettings)
val bridge = remember {
NuvioPlayerBridgeFactory.create()
@ -71,6 +76,10 @@ actual fun PlatformPlayerSurface(
bridge.retry()
}
override fun configureIosVideoOutput(settings: PlayerSettingsUiState) {
bridge.applyIosVideoOutputSettings(settings)
}
override fun setPlaybackSpeed(speed: Float) {
bridge.setPlaybackSpeed(speed)
}
@ -214,6 +223,7 @@ actual fun PlatformPlayerSurface(
// Load file and set initial state
LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) {
bridge.applyIosVideoOutputSettings(latestPlayerSettings.value)
bridge.loadFileWithAudio(
sourceUrl,
sourceAudioUrl,
@ -242,6 +252,10 @@ actual fun PlatformPlayerSurface(
)
}
LaunchedEffect(bridge, playerSettings) {
bridge.applyIosVideoOutputSettings(playerSettings)
}
// Polling for snapshots
LaunchedEffect(bridge) {
var lastReportedError: String? = null
@ -280,6 +294,24 @@ actual fun PlatformPlayerSurface(
)
}
private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) {
configureVideoOutput(
hardwareDecoder = settings.iosHardwareDecoderMode.mpvValue,
targetColorspaceHint = settings.iosTargetColorspaceHintEnabled,
toneMapping = settings.iosToneMappingMode.mpvValue,
hdrComputePeak = settings.iosHdrComputePeakEnabled,
targetPrimaries = settings.iosTargetPrimaries.mpvValue,
targetTransfer = settings.iosTargetTransfer.mpvValue,
extendedDynamicRange = settings.iosExtendedDynamicRangeEnabled,
deband = settings.iosDebandEnabled,
interpolation = settings.iosInterpolationEnabled,
brightness = settings.iosBrightness,
contrast = settings.iosContrast,
saturation = settings.iosSaturation,
gamma = settings.iosGamma,
)
}
private fun Color.toMpvColorString(): String {
val redInt = (red * 255f).toInt().coerceIn(0, 255)
val greenInt = (green * 255f).toInt().coerceIn(0, 255)

View file

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

View file

@ -28,6 +28,37 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) }
func retry() { playerVC?.retryPlayback() }
func configureVideoOutput(
hardwareDecoder: String,
targetColorspaceHint: Bool,
toneMapping: String,
hdrComputePeak: Bool,
targetPrimaries: String,
targetTransfer: String,
extendedDynamicRange: Bool,
deband: Bool,
interpolation: Bool,
brightness: Int32,
contrast: Int32,
saturation: Int32,
gamma: Int32
) {
playerVC?.configureVideoOutput(
hardwareDecoder: hardwareDecoder,
targetColorspaceHint: targetColorspaceHint,
toneMapping: toneMapping,
hdrComputePeak: hdrComputePeak,
targetPrimaries: targetPrimaries,
targetTransfer: targetTransfer,
extendedDynamicRange: extendedDynamicRange,
deband: deband,
interpolation: interpolation,
brightness: Int(brightness),
contrast: Int(contrast),
saturation: Int(saturation),
gamma: Int(gamma)
)
}
func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) }
func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) }
@ -204,6 +235,7 @@ final class MPVPlayerViewController: UIViewController {
metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale
metalLayer.framebufferOnly = true
metalLayer.backgroundColor = UIColor.black.cgColor
metalLayer.wantsExtendedDynamicRangeContent = true
view.layer.addSublayer(metalLayer)
layoutMetalLayer()
@ -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()