From 2df91085af33ee384bc2c35f4b174f2d6c22d99e Mon Sep 17 00:00:00 2001 From: MasterOfKay <144215097+MasterOfKay@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:07:28 +0200 Subject: [PATCH] Fixed TrueHD with atmos getting remuxed --- packages/app/src/context/defaultSettings.js | 1 + packages/app/src/services/playback.js | 7 +- packages/app/src/views/Player/WebOSPlayer.js | 38 ++++++++++- .../app/src/views/Settings/settingsSchema.js | 3 +- packages/platform-webos/src/deviceProfile.js | 67 +++++++++++++++---- packages/platform-webos/src/video.js | 7 +- 6 files changed, 102 insertions(+), 21 deletions(-) diff --git a/packages/app/src/context/defaultSettings.js b/packages/app/src/context/defaultSettings.js index 3239528c..9125dcc4 100644 --- a/packages/app/src/context/defaultSettings.js +++ b/packages/app/src/context/defaultSettings.js @@ -186,6 +186,7 @@ export const defaultSettings = { ac3Passthrough: true, eac3Passthrough: true, truehdPassthrough: true, + forceTruehdPassthrough: false, blockedRatings: [], showSeerrButton: true, // Synced but not shown anywhere in this app, so null keeps the TV from stamping a default diff --git a/packages/app/src/services/playback.js b/packages/app/src/services/playback.js index a1b8b172..555cb3f2 100644 --- a/packages/app/src/services/playback.js +++ b/packages/app/src/services/playback.js @@ -43,7 +43,9 @@ const DEFAULT_PASSTHROUGH_SETTINGS = { eac3Passthrough: true, dtsPassthrough: true, dtshdPassthrough: true, - truehdPassthrough: true + truehdPassthrough: true, + // Skip check and passtrough TueHD + forceTruehdPassthrough: false }; const getPlaybackAudioSettings = async (options = {}) => { @@ -58,7 +60,8 @@ const getPlaybackAudioSettings = async (options = {}) => { eac3Passthrough: options.eac3Passthrough ?? stored.eac3Passthrough ?? DEFAULT_PASSTHROUGH_SETTINGS.eac3Passthrough, dtsPassthrough: options.dtsPassthrough ?? stored.dtsPassthrough ?? DEFAULT_PASSTHROUGH_SETTINGS.dtsPassthrough, dtshdPassthrough: options.dtshdPassthrough ?? stored.dtshdPassthrough ?? DEFAULT_PASSTHROUGH_SETTINGS.dtshdPassthrough, - truehdPassthrough: options.truehdPassthrough ?? stored.truehdPassthrough ?? DEFAULT_PASSTHROUGH_SETTINGS.truehdPassthrough + truehdPassthrough: options.truehdPassthrough ?? stored.truehdPassthrough ?? DEFAULT_PASSTHROUGH_SETTINGS.truehdPassthrough, + forceTruehdPassthrough: options.forceTruehdPassthrough ?? stored.forceTruehdPassthrough ?? DEFAULT_PASSTHROUGH_SETTINGS.forceTruehdPassthrough }; }; diff --git a/packages/app/src/views/Player/WebOSPlayer.js b/packages/app/src/views/Player/WebOSPlayer.js index 0d93ffdf..3e9b5663 100644 --- a/packages/app/src/views/Player/WebOSPlayer.js +++ b/packages/app/src/views/Player/WebOSPlayer.js @@ -31,6 +31,7 @@ import {getSubtitleOverlayStyle, getSubtitleTextStyle, sanitizeSubtitleHtml, res import {isHdrOutput} from '../../utils/videoRange'; import {findPreferredAudioStream} from '../../utils/audioLanguage'; import {saveSubtitlePref} from '../../services/subtitlePrefs'; +import serverLogger from '../../services/serverLogger'; import {resolveInitialSubtitle} from './initialSubtitle'; import PlayerControls, {usePlayerButtons} from './PlayerControls'; import NextUpOverlay from './NextUpOverlay'; @@ -54,6 +55,8 @@ import {getVideoDisplayAspectRatio, getZoomDisplayRect} from './aspectRatioUtils import css from './WebOSPlayer.module.less'; +const TRUEHD_CODECS = ['truehd', 'mlp']; + const getWebOSFullscreenRect = () => { if (typeof window === 'undefined') { return {width: 1920, height: 1080}; @@ -625,12 +628,21 @@ const Player = ({item, resume, initialMediaSourceId, initialAudioIndex, initialS isLiveTV, hasUserData: !!item.UserData }); + // Force TrueHD passtrough if anabled. + const pinTruehdDirectPlay = Boolean(settings.forceTruehdPassthrough) && + (item?.MediaSources || []).some((source) => (source?.MediaStreams || []).some( + (s) => s.Type === 'Audio' && TRUEHD_CODECS.includes((s.Codec || '').toLowerCase()) + )); + const playbackInfoOptions = { startPositionTicks: startPosition, maxBitrate: selectedQuality || settings.maxBitrate, enableDirectPlay: !forceTranscode && !settings.preferTranscode, enableDirectStream: !forceTranscode && !settings.preferTranscode, - forceDirectPlay: (isLiveTV || forceTranscode) ? false : settings.forceDirectPlay, + forceTruehdPassthrough: settings.forceTruehdPassthrough, + forceDirectPlay: (isLiveTV || forceTranscode) + ? false + : (settings.forceDirectPlay || pinTruehdDirectPlay), mediaSourceId: initialMediaSourceId, audioStreamIndex: initialAudioIndex != null ? initialAudioIndex : undefined, subtitleStreamIndex: initialSubtitleIndex, @@ -719,6 +731,28 @@ const Player = ({item, resume, initialMediaSourceId, initialAudioIndex, initialS : null; } + // For logging purpos to see what the audio path is + const audioCaps = playback.getCurrentSession()?.capabilities; + serverLogger.playback('Playback: media opened', { + itemId: item?.Id, + playMethod: result.playMethod, + container: result.mediaSource?.Container, + forceTruehdPassthrough: Boolean(settings.forceTruehdPassthrough), + pinTruehdDirectPlay, + audioOutputStatus: audioCaps?.audioOutputStatus, + defaultAudioStreamIndex: result.defaultAudioStreamIndex, + selectedAudioStreamIndex: result.selectedAudioStreamIndex, + audioLanguageSetting: settings.audioLanguage, + audioStreams: (result.audioStreams || []).map((s) => ({ + index: s.index, + codec: s.codec, + profile: s.profile, + channels: s.channels, + language: s.language, + isDefault: s.isDefault + })) + }); + // Load subtitle data or renderer for the selected stream. const loadSubtitleData = async (sub) => { disposePgsRenderer(pgsRendererRef.current); @@ -897,7 +931,7 @@ const Player = ({item, resume, initialMediaSourceId, initialAudioIndex, initialS } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [item, resume, videoQueue, onPlayNext, selectedQuality, settings.maxBitrate, settings.preferTranscode, settings.forceDirectPlay, forceTranscode, settings.subtitleMode, settings.introAction, settings.outroAction, initialAudioIndex, initialSubtitleIndex]); + }, [item, resume, videoQueue, onPlayNext, selectedQuality, settings.maxBitrate, settings.preferTranscode, settings.forceDirectPlay, settings.forceTruehdPassthrough, forceTranscode, settings.subtitleMode, settings.introAction, settings.outroAction, initialAudioIndex, initialSubtitleIndex]); useEffect(() => { if (mediaUrl) { diff --git a/packages/app/src/views/Settings/settingsSchema.js b/packages/app/src/views/Settings/settingsSchema.js index f3b8e146..05c7505b 100644 --- a/packages/app/src/views/Settings/settingsSchema.js +++ b/packages/app/src/views/Settings/settingsSchema.js @@ -487,7 +487,8 @@ export const SETTINGS_SCHEMA = [ {kind: KIND.TOGGLE, key: 'passthroughEnabled', label: () => $L('Audio Passthrough'), desc: () => $L('Enable advanced bitstream passthrough for external audio devices'), icon: 'speaker'}, {kind: KIND.TOGGLE, key: 'ac3Passthrough', label: () => $L('AC3 Passthrough'), desc: () => $L('Allow Dolby Digital passthrough when available'), icon: 'speaker'}, {kind: KIND.TOGGLE, key: 'eac3Passthrough', label: () => $L('E-AC3 Passthrough'), desc: () => $L('Allow Dolby Digital Plus passthrough when available'), icon: 'speaker'}, - {kind: KIND.TOGGLE, key: 'truehdPassthrough', label: () => $L('TrueHD Passthrough (Experimental)'), desc: () => $L('Allow Dolby TrueHD passthrough when available'), icon: 'speaker'} + {kind: KIND.TOGGLE, key: 'truehdPassthrough', label: () => $L('TrueHD Passthrough (Experimental)'), desc: () => $L('Allow Dolby TrueHD passthrough when available'), icon: 'speaker'}, + {kind: KIND.TOGGLE, key: 'forceTruehdPassthrough', label: () => $L('Force TrueHD / Atmos Passthrough'), desc: () => $L('Send Dolby TrueHD and Atmos straight to your receiver. Make sure your receiver supports it.'), icon: 'speaker'} ] }, { diff --git a/packages/platform-webos/src/deviceProfile.js b/packages/platform-webos/src/deviceProfile.js index 46f8e4ff..e301fa74 100644 --- a/packages/platform-webos/src/deviceProfile.js +++ b/packages/platform-webos/src/deviceProfile.js @@ -8,7 +8,8 @@ const DEFAULT_PASSTHROUGH_SETTINGS = { eac3Passthrough: true, dtsPassthrough: true, dtshdPassthrough: true, - truehdPassthrough: true + truehdPassthrough: true, + forceTruehdPassthrough: false }; const resolvePassthroughSettings = (options = {}) => ({ @@ -18,26 +19,34 @@ const resolvePassthroughSettings = (options = {}) => ({ const emptyDtsSupport = {mkv: false, mp4: false, ts: false, avi: false}; -const LOSSLESS_PASSTHROUGH_OUTPUTS = new Set(['external_arc']); - const getActiveSoundOutput = (audioStatus = {}) => (audioStatus?.soundOutput || '').toString().toLowerCase().trim(); +/** + * Whether the audio path is an eARC/ARC connection to a receiver. + */ +const isArcSoundOutput = (audioStatus = {}) => /e?arc/.test(getActiveSoundOutput(audioStatus)); + +/** + * Checks if Bitstream passthrough is active, which requires an eARC/ARC connection to a receiver. + */ const isLosslessPassthroughOutput = (audioStatus = {}) => { - if (!LOSSLESS_PASSTHROUGH_OUTPUTS.has(getActiveSoundOutput(audioStatus))) return false; + if (!isArcSoundOutput(audioStatus)) return false; const digital = (audioStatus?.soundOutputDigital || '').toString().toLowerCase(); return digital !== 'pcm'; }; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const queryAudioOutputStatus = async () => { +const lunaRequest = async (service, method, parameters) => { try { const LS2Request = (await import('@enact/webos/LS2Request')).default; return await new Promise((resolve) => { + // eslint-disable-next-line new LS2Request().send({ - service: 'luna://com.webos.service.avoutput', - method: 'audio/getStatus', + service: `luna://${service}`, + method, + ...(parameters ? {parameters} : {}), onSuccess: resolve, onFailure: () => resolve({}) }); @@ -47,13 +56,35 @@ const queryAudioOutputStatus = async () => { } }; +const queryAudioOutputStatus = () => + lunaRequest('com.webos.service.avoutput', 'audio/getStatus'); + +/** + * The documented route to the sound path. com.webos.service.avoutput is not a + * public API and answers an empty object on current firmware, which read as "no + * receiver" and quietly disabled every passthrough check. + */ +const querySoundSettings = async () => { + const result = await lunaRequest('com.webos.settingsservice', 'getSystemSettings', { + category: 'sound', + keys: ['soundOutput', 'digitalSoundOutput'] + }); + return result?.settings || {}; +}; + const getAudioOutputStatus = async () => { for (let attempt = 0; attempt < 4; attempt++) { - const status = await queryAudioOutputStatus(); - if (getActiveSoundOutput(status)) return status; + const [status, sound] = await Promise.all([queryAudioOutputStatus(), querySoundSettings()]); + // Luna API returns empty objects. + const merged = { + ...status, + soundOutput: status?.soundOutput || sound.soundOutput, + soundOutputDigital: status?.soundOutputDigital || sound.digitalSoundOutput + }; + if (getActiveSoundOutput(merged)) return merged; await delay(150); } - return queryAudioOutputStatus(); + return {}; }; const applyAudioOutputPath = async (baseCaps) => { @@ -67,8 +98,9 @@ const applyAudioOutputPath = async (baseCaps) => { return { ...baseCaps, dts, - truehd: receiverPassthrough, dtshd: receiverPassthrough && !!(dts.mkv || dts.mp4 || dts.ts), + // TrueHD is fine. Only need to check if channels are preserved. + truehdBitstream: receiverPassthrough, audioOutputStatus, externalAudioPathActive: receiverPassthrough }; @@ -79,11 +111,16 @@ const applyPassthroughSettings = (caps, options = {}) => { const passthroughAllowed = prefs.passthroughEnabled; const dtsSupport = passthroughAllowed && prefs.dtsPassthrough ? (caps.dts || emptyDtsSupport) : emptyDtsSupport; + // Passrough is enabled in settings. + const forceTruehd = !!prefs.forceTruehdPassthrough; + const truehdAllowed = forceTruehd || (passthroughAllowed && !!prefs.truehdPassthrough); + return { ...caps, ac3: !!caps.ac3 && !!prefs.ac3Passthrough, eac3: !!caps.eac3 && !!prefs.eac3Passthrough, - truehd: !!caps.truehd && passthroughAllowed && !!prefs.truehdPassthrough, + truehd: forceTruehd || (!!caps.truehd && truehdAllowed), + truehdBitstream: forceTruehd || (!!caps.truehdBitstream && truehdAllowed), dts: dtsSupport, dtshd: !!caps.dtshd && passthroughAllowed && !!prefs.dtshdPassthrough }; @@ -307,7 +344,8 @@ export const getDeviceCapabilities = async () => { dtsBase, ac3: testAc3Support(), eac3: true, - truehd: false, + // If the reciver is connected via arc/eArc, TrueHD can go trough bitrseam. + truehd: true, dtshd: false, // the TV decodes FLAC to LPCM, so it direct plays in a video container // on modern webOS even though LG only lists it as a standalone format @@ -633,7 +671,8 @@ export const getJellyfinDeviceProfile = async (options = {}) => { const directPlayProfiles = buildDirectPlayProfiles(caps); const maxStreamingBitrate = 120_000_000; - const maxAudioChannels = caps.dolbyAtmos || caps.truehd || caps.dtshd ? '8' : '6'; + // LG says limit is scince WebOS 4,5 8, but it limits it to 6? + const maxAudioChannels = caps.dolbyAtmos || caps.truehdBitstream || caps.dtshd ? '8' : '6'; // fMP4 preserves DV RPU metadata; MPEG-TS strips it. Use fMP4 on webOS 5+. const hlsContainer = caps.nativeHlsFmp4 ? 'mp4' : 'ts'; diff --git a/packages/platform-webos/src/video.js b/packages/platform-webos/src/video.js index 15228115..9093bc5f 100644 --- a/packages/platform-webos/src/video.js +++ b/packages/platform-webos/src/video.js @@ -11,7 +11,8 @@ const DEFAULT_PASSTHROUGH_SETTINGS = { eac3Passthrough: true, dtsPassthrough: true, dtshdPassthrough: true, - truehdPassthrough: true + truehdPassthrough: true, + forceTruehdPassthrough: false }; const resolvePassthroughSettings = (options = {}) => ({ @@ -100,7 +101,9 @@ export const getSupportedAudioCodecs = (capabilities, container = '', passthroug if (dtsOk && capabilities.dtshd) codecs.push('dts-hd', 'dtshd', 'dtsx'); } - if (capabilities.truehd && passthroughAllowed && passthrough.truehdPassthrough) codecs.push('truehd', 'mlp'); + const truehdOk = passthrough.forceTruehdPassthrough || + (capabilities.truehd && passthroughAllowed && passthrough.truehdPassthrough); + if (truehdOk) codecs.push('truehd', 'mlp'); if (capabilities.opus) codecs.push('opus'); codecs.push('vorbis', 'wma', 'amr', 'amrnb', 'amrwb');