feat(MPV): Spatial Audio for ios

This commit is contained in:
tapframe 2026-06-06 21:48:35 +05:30
parent 24c005fe6c
commit 406ef99cfc
11 changed files with 138 additions and 7 deletions

2
MPVKit

@ -1 +1 @@
Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb
Subproject commit 2e81303b74762424f4187c905d532a716c8e1ff5

View file

@ -70,6 +70,7 @@ actual object PlayerSettingsStorage {
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 iosAudioOutputModeKey = "ios_audio_output_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"
@ -129,6 +130,7 @@ actual object PlayerSettingsStorage {
iosTargetPrimariesKey,
iosTargetTransferKey,
iosHardwareDecoderModeKey,
iosAudioOutputModeKey,
iosExtendedDynamicRangeEnabledKey,
iosTargetColorspaceHintEnabledKey,
iosHdrComputePeakEnabledKey,
@ -865,6 +867,13 @@ actual object PlayerSettingsStorage {
preferences?.edit()?.putString(ProfileScopedKey.of(iosHardwareDecoderModeKey), mode)?.apply()
}
actual fun loadIosAudioOutputMode(): String? =
preferences?.getString(ProfileScopedKey.of(iosAudioOutputModeKey), null)
actual fun saveIosAudioOutputMode(mode: String) {
preferences?.edit()?.putString(ProfileScopedKey.of(iosAudioOutputModeKey), mode)?.apply()
}
private fun loadIosBoolean(keyBase: String, defaultValue: Boolean): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(keyBase)
@ -994,6 +1003,7 @@ actual object PlayerSettingsStorage {
loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) }
loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) }
loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) }
loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) }
loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) }
loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) }
loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) }
@ -1061,6 +1071,7 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries)
payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer)
payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode)
payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode)
payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled)
payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled)
payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled)

View file

@ -1560,6 +1560,12 @@
<string name="settings_playback_ios_display_color_hint_desc">Let mpv target the active display color space by default.</string>
<string name="settings_playback_ios_target_primaries">Target primaries</string>
<string name="settings_playback_ios_target_transfer">Target transfer</string>
<!-- Playback settings iOS audio output -->
<string name="settings_playback_ios_audio_output_section">iOS audio output</string>
<string name="settings_playback_ios_audio_output">Audio output</string>
<string name="settings_playback_ios_audio_output_auto_desc">Try AVFoundation first, then fall back to AudioUnit.</string>
<string name="settings_playback_ios_audio_output_avfoundation_desc">Experimental support for Spatial Audio and multichannel output.</string>
<string name="settings_playback_ios_audio_output_audiounit_desc">Use the legacy AudioUnit output.</string>
<!-- Debrid Result Management section -->
<string name="settings_debrid_section_result_management">Result Management</string>
<string name="settings_debrid_max_results">Max results</string>
@ -1660,6 +1666,7 @@
<string name="submit_intro_capture">Capture</string>
<!-- iOS advanced playback -->
<string name="settings_playback_ios_hw_decoder_dialog">Hardware decoder</string>
<string name="settings_playback_ios_audio_output_dialog">Audio output</string>
<string name="settings_playback_ios_target_primaries_dialog">Target primaries</string>
<string name="settings_playback_ios_target_transfer_dialog">Target transfer</string>
<!-- Collection editor -->

View file

@ -146,6 +146,15 @@ enum class IosHardwareDecoderMode(
Off("no", "Off"),
}
enum class IosAudioOutputMode(
val mpvValue: String,
val label: String,
) {
Auto("avfoundation,audiounit,", "Auto"),
AvFoundation("avfoundation", "AVFoundation"),
AudioUnit("audiounit", "AudioUnit"),
}
@Composable
fun IosVideoOutputPreset.localizedLabel(): String = when (this) {
IosVideoOutputPreset.NativeEdr -> stringResource(Res.string.player_ios_preset_native_edr_label)

View file

@ -74,6 +74,7 @@ data class PlayerSettingsUiState(
val iosTargetPrimaries: IosTargetPrimaries = IosTargetPrimaries.Auto,
val iosTargetTransfer: IosTargetTransfer = IosTargetTransfer.Auto,
val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox,
val iosAudioOutputMode: IosAudioOutputMode = IosAudioOutputMode.Auto,
val iosExtendedDynamicRangeEnabled: Boolean = true,
val iosTargetColorspaceHintEnabled: Boolean = true,
val iosHdrComputePeakEnabled: Boolean = true,
@ -132,6 +133,7 @@ object PlayerSettingsRepository {
private var iosTargetPrimaries = IosTargetPrimaries.Auto
private var iosTargetTransfer = IosTargetTransfer.Auto
private var iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox
private var iosAudioOutputMode = IosAudioOutputMode.Auto
private var iosExtendedDynamicRangeEnabled = true
private var iosTargetColorspaceHintEnabled = true
private var iosHdrComputePeakEnabled = true
@ -195,6 +197,7 @@ object PlayerSettingsRepository {
iosTargetPrimaries = IosTargetPrimaries.Auto
iosTargetTransfer = IosTargetTransfer.Auto
iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox
iosAudioOutputMode = IosAudioOutputMode.Auto
iosExtendedDynamicRangeEnabled = true
iosTargetColorspaceHintEnabled = true
iosHdrComputePeakEnabled = true
@ -318,6 +321,9 @@ object PlayerSettingsRepository {
iosHardwareDecoderMode = PlayerSettingsStorage.loadIosHardwareDecoderMode()
?.let { runCatching { IosHardwareDecoderMode.valueOf(it) }.getOrNull() }
?: IosHardwareDecoderMode.VideoToolbox
iosAudioOutputMode = PlayerSettingsStorage.loadIosAudioOutputMode()
?.let { runCatching { IosAudioOutputMode.valueOf(it) }.getOrNull() }
?: IosAudioOutputMode.Auto
iosExtendedDynamicRangeEnabled = PlayerSettingsStorage.loadIosExtendedDynamicRangeEnabled() ?: true
iosTargetColorspaceHintEnabled = PlayerSettingsStorage.loadIosTargetColorspaceHintEnabled() ?: true
iosHdrComputePeakEnabled = PlayerSettingsStorage.loadIosHdrComputePeakEnabled() ?: true
@ -716,6 +722,13 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveIosHardwareDecoderMode(mode.name)
}
fun setIosAudioOutputMode(mode: IosAudioOutputMode) {
ensureLoaded()
iosAudioOutputMode = mode
publish()
PlayerSettingsStorage.saveIosAudioOutputMode(mode.name)
}
fun setIosExtendedDynamicRangeEnabled(enabled: Boolean) {
ensureLoaded()
iosVideoOutputPreset = IosVideoOutputPreset.Custom
@ -853,6 +866,7 @@ object PlayerSettingsRepository {
iosTargetPrimaries = iosTargetPrimaries,
iosTargetTransfer = iosTargetTransfer,
iosHardwareDecoderMode = iosHardwareDecoderMode,
iosAudioOutputMode = iosAudioOutputMode,
iosExtendedDynamicRangeEnabled = iosExtendedDynamicRangeEnabled,
iosTargetColorspaceHintEnabled = iosTargetColorspaceHintEnabled,
iosHdrComputePeakEnabled = iosHdrComputePeakEnabled,

View file

@ -106,6 +106,8 @@ internal expect object PlayerSettingsStorage {
fun saveIosTargetTransfer(transfer: String)
fun loadIosHardwareDecoderMode(): String?
fun saveIosHardwareDecoderMode(mode: String)
fun loadIosAudioOutputMode(): String?
fun saveIosAudioOutputMode(mode: String)
fun loadIosExtendedDynamicRangeEnabled(): Boolean?
fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean)
fun loadIosTargetColorspaceHintEnabled(): Boolean?

View file

@ -59,6 +59,7 @@ 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.IosAudioOutputMode
import com.nuvio.app.features.player.IosHardwareDecoderMode
import com.nuvio.app.features.player.localizedLabel
import com.nuvio.app.features.player.IosTargetPrimaries
@ -267,6 +268,7 @@ private fun PlaybackSettingsSection(
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
var showDecoderPriorityDialog by remember { mutableStateOf(false) }
var showHoldToSpeedValueDialog by remember { mutableStateOf(false) }
var showIosAudioOutputDialog by remember { mutableStateOf(false) }
var showIosHardwareDecoderDialog by remember { mutableStateOf(false) }
var showIosTargetPrimariesDialog by remember { mutableStateOf(false) }
var showIosTargetTransferDialog by remember { mutableStateOf(false) }
@ -782,6 +784,20 @@ private fun PlaybackSettingsSection(
}
if (isIos) {
SettingsSection(
title = stringResource(Res.string.settings_playback_ios_audio_output_section),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsNavigationRow(
title = stringResource(Res.string.settings_playback_ios_audio_output),
description = autoPlayPlayerSettings.iosAudioOutputMode.label,
isTablet = isTablet,
onClick = { showIosAudioOutputDialog = true },
)
}
}
SettingsSection(
title = stringResource(Res.string.settings_playback_ios_video_output),
isTablet = isTablet,
@ -1263,6 +1279,27 @@ private fun PlaybackSettingsSection(
)
}
if (showIosAudioOutputDialog) {
IosEnumSelectionDialog(
title = stringResource(Res.string.settings_playback_ios_audio_output_dialog),
options = IosAudioOutputMode.entries,
selected = autoPlayPlayerSettings.iosAudioOutputMode,
label = { it.label },
description = {
when (it) {
IosAudioOutputMode.Auto -> stringResource(Res.string.settings_playback_ios_audio_output_auto_desc)
IosAudioOutputMode.AvFoundation -> stringResource(Res.string.settings_playback_ios_audio_output_avfoundation_desc)
IosAudioOutputMode.AudioUnit -> stringResource(Res.string.settings_playback_ios_audio_output_audiounit_desc)
}
},
onSelect = {
PlayerSettingsRepository.setIosAudioOutputMode(it)
showIosAudioOutputDialog = false
},
onDismiss = { showIosAudioOutputDialog = false },
)
}
if (showIosTargetPrimariesDialog) {
IosEnumSelectionDialog(
title = stringResource(Res.string.settings_playback_ios_target_primaries_dialog),
@ -1890,6 +1927,7 @@ private fun <T> IosEnumSelectionDialog(
options: List<T>,
selected: T,
label: (T) -> String,
description: @Composable (T) -> String? = { null },
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
) {
@ -1918,6 +1956,7 @@ private fun <T> IosEnumSelectionDialog(
) {
options.forEach { option ->
val isSelected = option == selected
val optionDescription = description(option)
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
@ -1937,12 +1976,23 @@ private fun <T> IosEnumSelectionDialog(
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label(option),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
Column(
modifier = Modifier.weight(1f),
)
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = label(option),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
if (optionDescription != null) {
Text(
text = optionDescription,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,

View file

@ -30,6 +30,7 @@ interface NuvioPlayerBridge {
saturation: Int,
gamma: Int,
)
fun configureAudioOutput(audioOutput: String)
fun setPlaybackSpeed(speed: Float)
fun setMuted(muted: Boolean)
fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom

View file

@ -309,6 +309,7 @@ actual fun PlatformPlayerSurface(
}
private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) {
configureAudioOutput(audioOutput = settings.iosAudioOutputMode.mpvValue)
configureVideoOutput(
hardwareDecoder = settings.iosHardwareDecoderMode.mpvValue,
targetColorspaceHint = settings.iosTargetColorspaceHintEnabled,

View file

@ -68,6 +68,7 @@ actual object PlayerSettingsStorage {
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 iosAudioOutputModeKey = "ios_audio_output_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"
@ -127,6 +128,7 @@ actual object PlayerSettingsStorage {
iosTargetPrimariesKey,
iosTargetTransferKey,
iosHardwareDecoderModeKey,
iosAudioOutputModeKey,
iosExtendedDynamicRangeEnabledKey,
iosTargetColorspaceHintEnabledKey,
iosHdrComputePeakEnabledKey,
@ -735,6 +737,13 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosHardwareDecoderModeKey))
}
actual fun loadIosAudioOutputMode(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosAudioOutputModeKey))
actual fun saveIosAudioOutputMode(mode: String) {
NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosAudioOutputModeKey))
}
actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? =
loadBoolean(iosExtendedDynamicRangeEnabledKey)
@ -844,6 +853,7 @@ actual object PlayerSettingsStorage {
loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) }
loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) }
loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) }
loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) }
loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) }
loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) }
loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) }
@ -910,6 +920,7 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries)
payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer)
payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode)
payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode)
payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled)
payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled)
payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled)

View file

@ -59,6 +59,9 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
gamma: Int(gamma)
)
}
func configureAudioOutput(audioOutput: String) {
playerVC?.configureAudioOutput(audioOutput: audioOutput)
}
func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) }
func setMuted(muted: Bool) { playerVC?.setMuted(muted) }
func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) }
@ -192,6 +195,8 @@ private struct PendingLoadRequest {
final class MPVPlayerViewController: UIViewController {
private static let defaultAudioOutput = "avfoundation,audiounit,"
private let errorStateLock = NSLock()
private var metalLayer = MetalLayer()
private var lastAppliedDrawableSize: CGSize = .zero
@ -318,7 +323,8 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
checkError(mpv_set_option_string(mpv, "audio-channels", "stereo"))
checkError(mpv_set_option_string(mpv, "ao", Self.defaultAudioOutput))
checkError(mpv_set_option_string(mpv, "audio-channels", "auto"))
checkError(mpv_set_option_string(mpv, "audio-fallback-to-null", "yes"))
checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo"))
checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1"))
@ -512,6 +518,11 @@ final class MPVPlayerViewController: UIViewController {
setVideoEqualizer("gamma", gamma)
}
func configureAudioOutput(audioOutput: String) {
guard mpv != nil else { return }
setStringProperty("ao", audioOutput)
}
func setSpeed(_ speed: Float) {
guard mpv != nil else { return }
var s = Double(speed)
@ -853,6 +864,7 @@ final class MPVPlayerViewController: UIViewController {
self.clearPlaybackError()
self.isPlayerLoading = false
self.updateState()
self.logCurrentAudioOutput()
}
case MPV_EVENT_END_FILE:
if let data = eventPtr.pointee.data {
@ -943,6 +955,19 @@ final class MPVPlayerViewController: UIViewController {
return Int(data)
}
private func logCurrentAudioOutput() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self, self.mpv != nil else { return }
let currentAo = self.getString("current-ao") ?? "unknown"
let channels = self.getString("audio-out-params/hr-channels")
?? self.getString("audio-params/hr-channels")
?? "unknown"
let channelCount = self.getInt("audio-out-params/channel-count")
let codec = self.getString("audio-codec-name") ?? "unknown"
print("[MPV] Audio output: ao=\(currentAo), channels=\(channels), channelCount=\(channelCount), codec=\(codec)")
}
}
private func checkError(_ status: CInt) {
if status < 0 {
print("[MPV] API error: \(String(cString: mpv_error_string(status)))")