From 8d9cbbccbfd067b8721ef3324fdfcdc5ba15bb39 Mon Sep 17 00:00:00 2001 From: Umar Jamil Date: Sat, 18 Jul 2026 04:18:07 +0500 Subject: [PATCH] feat(player): add Windows HDMI audio bitstream passthrough Allow optional WASAPI exclusive SPDIF passthrough for Dolby/DTS formats on Windows when connected to an AV receiver, with decoded PCM fallback when the toggle is off. --- .../player/PlayerSettingsStorage.android.kt | 3 ++ .../composeResources/values/strings.xml | 4 +++ .../player/PlayerSettingsRepository.kt | 13 +++++++++ .../features/player/PlayerSettingsStorage.kt | 2 ++ .../features/settings/PlaybackSettingsPage.kt | 15 ++++++++++ .../features/player/PlayerEngine.desktop.kt | 4 +++ .../player/PlayerSettingsStorage.desktop.kt | 4 +++ .../player/desktop/NativePlayerBridge.kt | 1 + .../player/desktop/NativePlayerController.kt | 15 +++++++++- .../desktopMain/native/macos/player_bridge.mm | 1 + .../native/windows/player_bridge.cpp | 28 +++++++++++++++---- .../player/PlayerSettingsStorage.ios.kt | 4 +++ 12 files changed, 87 insertions(+), 7 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 9bc61d7f1..7fcb7d815 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -1149,6 +1149,9 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadWindowsHdmiPassthroughEnabled(): Boolean? = null + + actual fun saveWindowsHdmiPassthroughEnabled(enabled: Boolean) = Unit actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f291fcc07..50a111615 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1935,6 +1935,10 @@ Custom Use your advanced values below. Off + + Windows audio + HDMI audio passthrough + Send Dolby Digital, Dolby Digital Plus/Atmos, TrueHD/Atmos, DTS, DTS-HD, and DTS:X unchanged to the Windows default HDMI receiver. The receiver controls volume; unsupported audio plays as decoded PCM. NVIDIA RTX Video RTX Video Super Resolution diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index df6e40cc0..4be9929d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -95,6 +95,7 @@ data class PlayerSettingsUiState( val iosSaturation: Int = 0, val iosGamma: Int = 0, val nvidiaRtxSuperResolutionEnabled: Boolean = false, + val windowsHdmiPassthroughEnabled: Boolean = false, ) object PlayerSettingsRepository { @@ -164,6 +165,7 @@ object PlayerSettingsRepository { private var iosSaturation = 0 private var iosGamma = 0 private var nvidiaRtxSuperResolutionEnabled = false + private var windowsHdmiPassthroughEnabled = false fun ensureLoaded() { if (hasLoaded) return @@ -238,6 +240,7 @@ object PlayerSettingsRepository { iosSaturation = 0 iosGamma = 0 nvidiaRtxSuperResolutionEnabled = false + windowsHdmiPassthroughEnabled = false publish() } @@ -389,6 +392,7 @@ object PlayerSettingsRepository { iosSaturation = PlayerSettingsStorage.loadIosSaturation() ?: 0 iosGamma = PlayerSettingsStorage.loadIosGamma() ?: 0 nvidiaRtxSuperResolutionEnabled = PlayerSettingsStorage.loadNvidiaRtxSuperResolutionEnabled() ?: false + windowsHdmiPassthroughEnabled = PlayerSettingsStorage.loadWindowsHdmiPassthroughEnabled() ?: false publish() } @@ -794,6 +798,14 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveNvidiaRtxSuperResolutionEnabled(enabled) } + fun setWindowsHdmiPassthroughEnabled(enabled: Boolean) { + ensureLoaded() + if (windowsHdmiPassthroughEnabled == enabled) return + windowsHdmiPassthroughEnabled = enabled + publish() + PlayerSettingsStorage.saveWindowsHdmiPassthroughEnabled(enabled) + } + fun setLibassRenderType(renderType: String) { ensureLoaded() if (libassRenderType == renderType) return @@ -1031,6 +1043,7 @@ object PlayerSettingsRepository { iosSaturation = iosSaturation, iosGamma = iosGamma, nvidiaRtxSuperResolutionEnabled = nvidiaRtxSuperResolutionEnabled, + windowsHdmiPassthroughEnabled = windowsHdmiPassthroughEnabled, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 108fa1da5..ac22d381c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -148,6 +148,8 @@ internal expect object PlayerSettingsStorage { fun saveIosGamma(value: Int) fun loadNvidiaRtxSuperResolutionEnabled(): Boolean? fun saveNvidiaRtxSuperResolutionEnabled(enabled: Boolean) + fun loadWindowsHdmiPassthroughEnabled(): Boolean? + fun saveWindowsHdmiPassthroughEnabled(enabled: Boolean) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 0c63b92b6..12b59594b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -986,6 +986,21 @@ private fun PlaybackSettingsSection( } if (isWindows) { + SettingsSection( + title = stringResource(Res.string.settings_playback_windows_audio_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_hdmi_passthrough), + description = stringResource(Res.string.settings_playback_hdmi_passthrough_desc), + checked = autoPlayPlayerSettings.windowsHdmiPassthroughEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setWindowsHdmiPassthroughEnabled, + ) + } + } + SettingsSection( title = stringResource(Res.string.settings_playback_nvidia_rtx_video_section), isTablet = isTablet, diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt index e27a54286..cf3d9ee61 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt @@ -119,6 +119,8 @@ private fun NativePlayerSurface( val playerSettings by PlayerSettingsRepository.uiState.collectAsState() val decoderPriority = playerSettings.decoderPriority val nvidiaRtxSuperResolutionEnabled = playerSettings.nvidiaRtxSuperResolutionEnabled + val windowsHdmiPassthroughEnabled = + DesktopHostOs.current == DesktopHostOs.WINDOWS && playerSettings.windowsHdmiPassthroughEnabled LaunchedEffect(controller, sourceUrl, playbackHeaders) { onControllerReady(controller) @@ -163,6 +165,7 @@ private fun NativePlayerSurface( playbackHeaders, decoderPriority, nvidiaRtxSuperResolutionEnabled, + windowsHdmiPassthroughEnabled, hostFirstFullSizePaintComplete.value, initialPositionMs, initialPositionRequestKey, @@ -178,6 +181,7 @@ private fun NativePlayerSurface( initialPositionMs = initialPositionMs, decoderPriority = decoderPriority, nvidiaRtxSuperResolutionEnabled = nvidiaRtxSuperResolutionEnabled, + windowsHdmiPassthroughEnabled = windowsHdmiPassthroughEnabled, onError = { message -> latestOnError.value(message) }, ) initialPositionRequestKey?.let { key -> diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt index 401e31fb7..fe04c0c48 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.desktop.kt @@ -89,6 +89,7 @@ internal actual object PlayerSettingsStorage { private const val iosSaturationKey = "ios_saturation" private const val iosGammaKey = "ios_gamma" private const val nvidiaRtxSuperResolutionEnabledKey = "nvidia_rtx_super_resolution_enabled" + private const val windowsHdmiPassthroughEnabledKey = "windows_hdmi_passthrough_enabled" private val syncKeys = listOf( showLoadingOverlayKey, showParentalGuideKey, @@ -312,6 +313,9 @@ internal actual object PlayerSettingsStorage { actual fun loadNvidiaRtxSuperResolutionEnabled(): Boolean? = loadBoolean(nvidiaRtxSuperResolutionEnabledKey) actual fun saveNvidiaRtxSuperResolutionEnabled(enabled: Boolean) = saveBoolean(nvidiaRtxSuperResolutionEnabledKey, enabled) + actual fun loadWindowsHdmiPassthroughEnabled(): Boolean? = loadBoolean(windowsHdmiPassthroughEnabledKey) + actual fun saveWindowsHdmiPassthroughEnabled(enabled: Boolean) = + saveBoolean(windowsHdmiPassthroughEnabledKey, enabled) private fun scoped(key: String): String = ProfileScopedKey.of(key) private fun loadString(key: String): String? = store.getString(scoped(key)) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt index 8533e90d6..070e834e2 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt @@ -35,6 +35,7 @@ internal object NativePlayerBridge { controlsPageUrl: String, decoderPriority: Int, nvidiaRtxSuperResolutionEnabled: Boolean, + windowsHdmiPassthroughEnabled: Boolean, eventSink: NativePlayerEventSink, ): Long diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt index 8e1a0f13d..136ef02e1 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt @@ -74,6 +74,7 @@ internal class NativePlayerController( initialPositionMs: Long, decoderPriority: Int, nvidiaRtxSuperResolutionEnabled: Boolean, + windowsHdmiPassthroughEnabled: Boolean, onError: (String?) -> Unit, ) { val pending = PendingSource( @@ -83,12 +84,14 @@ internal class NativePlayerController( initialPositionMs = initialPositionMs.coerceAtLeast(0L), decoderPriority = decoderPriority, nvidiaRtxSuperResolutionEnabled = nvidiaRtxSuperResolutionEnabled, + windowsHdmiPassthroughEnabled = windowsHdmiPassthroughEnabled, onError = onError, ) pendingSource = pending log.d { "attach requested source=${sourceUrl.toPlaybackLogKey()} headers=${sourceHeaders.size} " + - "playWhenReady=$playWhenReady initialPositionMs=$initialPositionMs decoderPriority=$decoderPriority" + "playWhenReady=$playWhenReady initialPositionMs=$initialPositionMs decoderPriority=$decoderPriority " + + "hdmiPassthrough=$windowsHdmiPassthroughEnabled" } host.onPeerReady = { attachPending() } if (host.isDisplayable) { @@ -159,6 +162,7 @@ internal class NativePlayerController( controlsPageUrl = NativePlayerBridge.controlsPageUrl, decoderPriority = pending.decoderPriority, nvidiaRtxSuperResolutionEnabled = pending.nvidiaRtxSuperResolutionEnabled, + windowsHdmiPassthroughEnabled = pending.windowsHdmiPassthroughEnabled, eventSink = eventSink, ).also { if (it == 0L) error("Native player did not return a handle.") } }.onSuccess { created -> @@ -346,6 +350,7 @@ internal class NativePlayerController( } private fun setFallbackVolume(level: Float) { + if (pendingSource?.windowsHdmiPassthroughEnabled == true) return val current = handle if (current != 0L) { val nextLevel = level.coerceIn(0f, 1f) @@ -360,6 +365,10 @@ internal class NativePlayerController( private fun applyRememberedVolume() { val current = handle if (current == 0L) return + if (pendingSource?.windowsHdmiPassthroughEnabled == true) { + controlsState = controlsState.copy(volumeLevel = 1f) + return + } val level = rememberedVolumeLevel.coerceIn(0f, 1f) NativePlayerBridge.setVolume(current, level) controlsState = controlsState.copy(volumeLevel = level) @@ -374,6 +383,7 @@ internal class NativePlayerController( } private fun cycleFallbackSpeed() { + if (pendingSource?.windowsHdmiPassthroughEnabled == true) return val current = handle if (current == 0L) return val speeds = listOf(1f, 1.25f, 1.5f, 2f) @@ -450,12 +460,14 @@ internal class NativePlayerController( initialPositionMs = pending.initialPositionMs, decoderPriority = pending.decoderPriority, nvidiaRtxSuperResolutionEnabled = pending.nvidiaRtxSuperResolutionEnabled, + windowsHdmiPassthroughEnabled = pending.windowsHdmiPassthroughEnabled, onError = pending.onError, ) } override fun setPlaybackSpeed(speed: Float) { log.d { "setPlaybackSpeed speed=$speed handle=$handle" } + if (pendingSource?.windowsHdmiPassthroughEnabled == true) return handle.takeIf { it != 0L }?.let { NativePlayerBridge.setSpeed(it, speed) } } @@ -666,6 +678,7 @@ private data class PendingSource( val initialPositionMs: Long, val decoderPriority: Int, val nvidiaRtxSuperResolutionEnabled: Boolean, + val windowsHdmiPassthroughEnabled: Boolean, val onError: (String?) -> Unit, ) diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm index dee7cc7e7..1138268e5 100644 --- a/composeApp/src/desktopMain/native/macos/player_bridge.mm +++ b/composeApp/src/desktopMain/native/macos/player_bridge.mm @@ -2543,6 +2543,7 @@ static void throwJavaError(JNIEnv *env, NSString *message) { jstring controlsPageUrl, jint decoderPriority, jboolean nvidiaRtxSuperResolutionEnabled, + jboolean /* windowsHdmiPassthroughEnabled */, jobject eventSink ) { NSView *hostView = (__bridge NSView *)(void *)(intptr_t)hostViewPtr; diff --git a/composeApp/src/desktopMain/native/windows/player_bridge.cpp b/composeApp/src/desktopMain/native/windows/player_bridge.cpp index a97bccd9e..1b6925364 100644 --- a/composeApp/src/desktopMain/native/windows/player_bridge.cpp +++ b/composeApp/src/desktopMain/native/windows/player_bridge.cpp @@ -849,6 +849,7 @@ class WindowsMpvWebPlayer : public std::enable_shared_from_this(); auto self = shared_from_this(); uiThread = std::thread( - [self, sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, decoderPriority, nvidiaRtxSuperResolutionEnabled, initState]() { - self->runNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, decoderPriority, nvidiaRtxSuperResolutionEnabled, initState); + [self, sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, decoderPriority, nvidiaRtxSuperResolutionEnabled, windowsHdmiPassthroughEnabled, initState]() { + self->runNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, decoderPriority, nvidiaRtxSuperResolutionEnabled, windowsHdmiPassthroughEnabled, initState); } ); @@ -1302,11 +1303,12 @@ class WindowsMpvWebPlayer : public std::enable_shared_from_this initState ) { std::string failure; try { - initializeOnNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, decoderPriority, nvidiaRtxSuperResolutionEnabled); + initializeOnNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, decoderPriority, nvidiaRtxSuperResolutionEnabled, windowsHdmiPassthroughEnabled); } catch (const std::exception &error) { failure = error.what(); cleanupUiResources(); @@ -1337,7 +1339,8 @@ class WindowsMpvWebPlayer : public std::enable_shared_from_this