From 3166d933025529ec15b49c2ed0c5c192690df0fc Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 27 Sep 2026 14:12:08 +0200 Subject: [PATCH] fix(details): preselect the audio track the player will start on Details took the server's DefaultAudioStreamIndex while both players pick with selectPreferredAudioStream, so the audio picker and the file info could name a track playback never started on. Co-Authored-By: Claude Opus 5.5 --- packages/app/src/services/playback.js | 2 +- packages/app/src/views/Details/Details.js | 14 ++++------ .../app/src/views/Details/useDetailsItem.js | 20 +++----------- packages/app/src/views/Player/initialAudio.js | 10 +++++++ .../app/src/views/Player/initialAudio.test.js | 26 +++++++++++++++++++ 5 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 packages/app/src/views/Player/initialAudio.test.js diff --git a/packages/app/src/services/playback.js b/packages/app/src/services/playback.js index 94c112bce..4bc172bff 100644 --- a/packages/app/src/services/playback.js +++ b/packages/app/src/services/playback.js @@ -297,7 +297,7 @@ const buildPlaybackUrl = (itemId, mediaSource, playSessionId, playMethod, creden throw new Error('No playback URL available'); }; -const extractAudioStreams = (mediaSource) => { +export const extractAudioStreams = (mediaSource) => { if (!mediaSource.MediaStreams) return []; return mediaSource.MediaStreams .filter(s => s.Type === 'Audio') diff --git a/packages/app/src/views/Details/Details.js b/packages/app/src/views/Details/Details.js index 837a610c8..5ca6f9928 100644 --- a/packages/app/src/views/Details/Details.js +++ b/packages/app/src/views/Details/Details.js @@ -8,6 +8,8 @@ import {useSettings} from '../../context/SettingsContext'; import {useSeerr} from '../../context/SeerrContext'; import {useSyncPlay} from '../../context/SyncPlayContext'; import * as jellyfinApi from '../../services/jellyfinApi'; +import {extractAudioStreams} from '../../services/playback'; +import {initialAudioPosition} from '../Player/initialAudio'; import ModernDetailContent from './ModernDetailContent'; import DetailSkeleton from './DetailSkeleton'; import {formatDuration, getImageUrl, getBackdropId, getLogoUrl} from '../../utils/helpers'; @@ -572,19 +574,13 @@ const Details = ({itemId: itemIdProp, initialItem, onPlay, onSelectItem, onSelec } }, [remoteSubtitleResults, item, effectiveApi, selectedVersionIndex, closeModal, tagWithServerInfo, setItem, setSelectedSubtitleIndex]); - const handleSelectVersion = useCallback((e) => { + const handleSelectVersion = useCallback(async (e) => { const index = parseInt(e.currentTarget.dataset.index, 10); if (isNaN(index) || !item?.MediaSources?.[index]) return; setSelectedVersionIndex(index); const ms = item.MediaSources[index]; - const versionAudioStreams = ms.MediaStreams?.filter(s => s.Type === 'Audio') || []; const versionSubtitleStreams = ms.MediaStreams?.filter(s => s.Type === 'Subtitle') || []; - if (ms.DefaultAudioStreamIndex != null) { - const idx = versionAudioStreams.findIndex(s => s.Index === ms.DefaultAudioStreamIndex); - setSelectedAudioIndex(idx >= 0 ? idx : 0); - } else { - setSelectedAudioIndex(0); - } + setSelectedAudioIndex(await initialAudioPosition(item, extractAudioStreams(ms), settings)); // A different version brings its own tracks, so whatever was picked for the old // one no longer stands. subtitleChosenRef.current = false; @@ -596,7 +592,7 @@ const Details = ({itemId: itemIdProp, initialItem, onPlay, onSelectItem, onSelec setSelectedSubtitleIndex(-1); } closeModal(); - }, [item, closeModal, setSelectedVersionIndex, setSelectedAudioIndex, setSelectedSubtitleIndex]); + }, [item, settings, closeModal, setSelectedVersionIndex, setSelectedAudioIndex, setSelectedSubtitleIndex]); const handleSeasonSelect = useCallback((ev) => { const seasonId = ev.currentTarget.dataset.seasonId; diff --git a/packages/app/src/views/Details/useDetailsItem.js b/packages/app/src/views/Details/useDetailsItem.js index 95a208420..3c575fe27 100644 --- a/packages/app/src/views/Details/useDetailsItem.js +++ b/packages/app/src/views/Details/useDetailsItem.js @@ -2,8 +2,9 @@ import {useState, useEffect, useCallback, useMemo, useRef} from 'react'; import $L from '@enact/i18n/$L'; import * as playback from '../../services/playback'; +import {initialAudioPosition} from '../Player/initialAudio'; import {fetchTmdbSeasonRatings, resolveSeriesTmdbId, isRatingSourceAllowed} from '../../services/mdblistApi'; -import {getItemSubtitlePref, getSeriesSubtitlePref, getSeriesAudioPref} from '../../services/subtitlePrefs'; +import {getItemSubtitlePref, getSeriesSubtitlePref} from '../../services/subtitlePrefs'; import {fromServerStream, matchSeriesTrackIndex} from '../../utils/seriesTrackPrefs'; import {findParentCollections} from './parentCollection'; import {canScoreSeedLocally, getOnlineRecommendations, getRecommendations, mergeRecommendations} from '../../services/homeRecommendations'; @@ -187,23 +188,8 @@ const useDetailsItem = ({itemId, initialItem, effectiveApi, effectiveServerUrl, setSelectedVersionIndex(0); const ms = data.MediaSources?.[0]; if (ms) { - const initAudioStreams = ms.MediaStreams?.filter(s => s.Type === 'Audio') || []; const initSubtitleStreams = ms.MediaStreams?.filter(s => s.Type === 'Subtitle') || []; - // A track remembered for the series shows as active, and only when there - // is none does the server's own default stand in. - const seriesAudioPref = data.SeriesId ? await getSeriesAudioPref(data.SeriesId) : undefined; - const matchedAudio = seriesAudioPref - ? matchSeriesTrackIndex(initAudioStreams.map(fromServerStream), seriesAudioPref) - : null; - const rememberedAudioPos = matchedAudio !== null && matchedAudio >= 0 - ? initAudioStreams.findIndex(s => s.Index === matchedAudio) - : -1; - if (rememberedAudioPos >= 0) { - setSelectedAudioIndex(rememberedAudioPos); - } else if (ms.DefaultAudioStreamIndex != null) { - const idx = initAudioStreams.findIndex(s => s.Index === ms.DefaultAudioStreamIndex); - if (idx >= 0) setSelectedAudioIndex(idx); - } + setSelectedAudioIndex(await initialAudioPosition(data, playback.extractAudioStreams(ms), settingsRef.current)); // Show the remembered pick as active so it doesn't look like it needs // reselecting. The per-item index restores the exact track, and an episode // otherwise inherits its series' remembered language. diff --git a/packages/app/src/views/Player/initialAudio.js b/packages/app/src/views/Player/initialAudio.js index 8505aa920..b2146fded 100644 --- a/packages/app/src/views/Player/initialAudio.js +++ b/packages/app/src/views/Player/initialAudio.js @@ -1,4 +1,5 @@ import {getSeriesAudioPref} from '../../services/subtitlePrefs'; +import {selectPreferredAudioStream} from '../../utils/audioTrackSelection'; import {matchSeriesTrackIndex} from '../../utils/seriesTrackPrefs'; /** @@ -16,3 +17,12 @@ export const resolveSeriesAudio = async (item, audioStreams) => { return audioStreams.find((stream) => stream.index === matched) || null; }; + +/** + * Where among these audio tracks the player starts when none is picked, so Details + * can show that track. + */ +export const initialAudioPosition = async (item, audioStreams, settings) => { + const start = await resolveSeriesAudio(item, audioStreams) || selectPreferredAudioStream(audioStreams, settings); + return Math.max(0, audioStreams.indexOf(start)); +}; diff --git a/packages/app/src/views/Player/initialAudio.test.js b/packages/app/src/views/Player/initialAudio.test.js new file mode 100644 index 000000000..e303cb50b --- /dev/null +++ b/packages/app/src/views/Player/initialAudio.test.js @@ -0,0 +1,26 @@ +import {initialAudioPosition} from './initialAudio'; +import {getSeriesAudioPref} from '../../services/subtitlePrefs'; + +jest.mock('../../services/subtitlePrefs', () => ({ + getSeriesAudioPref: jest.fn() +})); + +const jpn = {index: 1, language: 'jpn', isDefault: true, channels: 2}; +const spa = {index: 2, language: 'spa', isDefault: false, channels: 2}; +const episode = {Id: 'episode-1', SeriesId: 'series-1'}; + +beforeEach(() => { + getSeriesAudioPref.mockResolvedValue(undefined); +}); + +describe('initialAudioPosition', () => { + test('is the fallback language rather than the default track, as the player picks', async () => { + const settings = {audioLanguage: 'cat', fallbackAudioLanguage: 'spa'}; + expect(await initialAudioPosition(episode, [jpn, spa], settings)).toBe(1); + }); + + test('puts the track remembered for the series before the language preferences', async () => { + getSeriesAudioPref.mockResolvedValue({language: 'jpn', title: '', relativeIndex: 0}); + expect(await initialAudioPosition(episode, [jpn, spa], {audioLanguage: 'spa'})).toBe(0); + }); +});