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 6d6cb1443..214b0728b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1952,6 +1952,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 89cb42fba..205e278e5 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 @@ -126,6 +126,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 SideEffect { onControllerReady(controller) @@ -188,6 +190,7 @@ private fun NativePlayerSurface( playbackHeaders, decoderPriority, nvidiaRtxSuperResolutionEnabled, + windowsHdmiPassthroughEnabled, hostFirstFullSizePaintComplete.value, initialPositionMs, initialPositionRequestKey, @@ -203,6 +206,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 9d78dea90..061a9a95e 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 @@ -43,6 +43,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 b108d3f59..c54a99f09 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 @@ -43,6 +43,7 @@ internal typealias NativePlayerCreate = ( String, Int, Boolean, + Boolean, NativePlayerEventSink, ) -> Long @@ -113,6 +114,7 @@ internal class NativePlayerController( initialPositionMs: Long, decoderPriority: Int, nvidiaRtxSuperResolutionEnabled: Boolean, + windowsHdmiPassthroughEnabled: Boolean, onError: (String?) -> Unit, ) { val pending = PendingSource( @@ -122,6 +124,7 @@ internal class NativePlayerController( initialPositionMs = initialPositionMs.coerceAtLeast(0L), decoderPriority = decoderPriority, nvidiaRtxSuperResolutionEnabled = nvidiaRtxSuperResolutionEnabled, + windowsHdmiPassthroughEnabled = windowsHdmiPassthroughEnabled, onError = onError, ) var terminalFailure: String? = null @@ -144,7 +147,8 @@ internal class NativePlayerController( } 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 (isHostDisplayable()) { @@ -296,6 +300,7 @@ internal class NativePlayerController( NativePlayerBridge.controlsPageUrl, pending.decoderPriority, pending.nvidiaRtxSuperResolutionEnabled, + pending.windowsHdmiPassthroughEnabled, eventSink, ).also { if (it == 0L) error("Native player did not return a handle.") } }.onSuccess { created -> @@ -546,6 +551,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) @@ -570,6 +576,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) @@ -584,6 +594,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) @@ -873,12 +884,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) } } @@ -1089,6 +1102,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/linux/player_bridge.cpp b/composeApp/src/desktopMain/native/linux/player_bridge.cpp index 5c3d47f11..97ab84f04 100644 --- a/composeApp/src/desktopMain/native/linux/player_bridge.cpp +++ b/composeApp/src/desktopMain/native/linux/player_bridge.cpp @@ -1457,7 +1457,8 @@ JNIEXPORT jlong JNICALL NP(create)( JNIEnv *env, jobject /*thiz*/, jlong hostViewPtr, jstring sourceUrl, jobjectArray headerLines, jboolean playWhenReady, jlong initialPositionMs, jstring controlsPageUrl, jint decoderPriority, - jboolean /*nvidiaRtxSuperResolutionEnabled*/, jobject eventSink) { + jboolean /*nvidiaRtxSuperResolutionEnabled*/, + jboolean /*windowsHdmiPassthroughEnabled*/, jobject eventSink) { // libmpv requires LC_NUMERIC=C (e.g. non-"C" locales with comma // decimals make mpv_create fail); the JVM uses java.util.Locale, so 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 +private val failNativeCreate: NativePlayerCreate = { _, _, _, _, _, _, _, _, _, _ -> error("native create must not run in lifecycle unit tests") } @@ -52,6 +52,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = {}, ) @@ -78,7 +79,7 @@ class NativePlayerControllerTeardownTest { val disposedHandles = Collections.synchronizedList(mutableListOf()) val controller = NativePlayerController( host = NativePlayerHost(), - nativeCreate = { _, source, _, _, _, _, _, _, _ -> + nativeCreate = { _, source, _, _, _, _, _, _, _, _ -> createdSources += source when (source) { sourceA -> { @@ -113,6 +114,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = {}, ) assertTrue(createAStarted.await(2, TimeUnit.SECONDS)) @@ -125,6 +127,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = {}, ) @@ -174,6 +177,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = { errors.incrementAndGet() }, ) SwingUtilities.invokeAndWait {} @@ -234,6 +238,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = {}, ) SwingUtilities.invokeAndWait {} @@ -251,6 +256,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = { replacementErrors.incrementAndGet() }, ) allowEdt.countDown() @@ -293,6 +299,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = { errors.incrementAndGet() }, ) SwingUtilities.invokeAndWait {} @@ -330,6 +337,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = { errors.incrementAndGet() }, ) @@ -356,6 +364,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = {}, ) @@ -425,6 +434,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = { retryError.countDown() }, ) controller.startTrackedDisposeForTest(42L) @@ -471,6 +481,7 @@ class NativePlayerControllerTeardownTest { initialPositionMs = 0L, decoderPriority = 0, nvidiaRtxSuperResolutionEnabled = false, + windowsHdmiPassthroughEnabled = false, onError = { replacementError.countDown() }, ) SwingUtilities.invokeAndWait {} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index aef697c46..0ca9f839f 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -947,6 +947,10 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(nvidiaRtxSuperResolutionEnabledKey)) } + actual fun loadWindowsHdmiPassthroughEnabled(): Boolean? = null + + actual fun saveWindowsHdmiPassthroughEnabled(enabled: Boolean) = Unit + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } loadShowParentalGuide()?.let { put(showParentalGuideKey, encodeSyncBoolean(it)) }