From 6876ce046f19c80b85f1c7968d9fc9b96c26743e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 20:15:24 +0200 Subject: [PATCH] fix(player): retain subtitle language and mode across episodes Carry the live subtitle/audio selection to the next episode on all TV navigation paths (next/prev buttons, autoplay) and feed TV subtitle modal selections back into player state via onSubtitleIndexChange so the chosen track is what gets carried. Rank subtitles by language plus forced/hearing-impaired mode, with a no-language fallback (mode + codec + position), and use an explicit match flag so a deliberate "off" selection is retained too. --- app/(auth)/player/direct-player.tsx | 42 +++--- .../video-player/controls/Controls.tv.tsx | 45 +++--- utils/jellyfin/getDefaultPlaySettings.ts | 16 ++- utils/streamRanker.ts | 128 +++++++++++++++--- 4 files changed, 167 insertions(+), 64 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index d14bf21fc..a384e94cf 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -185,11 +185,11 @@ export default function DirectPlayerPage() { return undefined; }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]); - // Initialize TV audio/subtitle indices from URL params + // Initialize TV audio/subtitle indices from URL params. + // No undefined guard: when a new episode's URL omits audioIndex, reset to + // undefined (media default) rather than leaking the previous episode's track. useEffect(() => { - if (audioIndex !== undefined) { - setCurrentAudioIndex(audioIndex); - } + setCurrentAudioIndex(audioIndex); }, [audioIndex]); useEffect(() => { @@ -470,8 +470,11 @@ export default function DirectPlayerPage() { return { ItemId: item.Id, - AudioStreamIndex: audioIndex ? audioIndex : undefined, - SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + // Report the live selection so server-side session/resume state reflects + // mid-playback track changes. Note: index 0 is valid (don't treat as + // falsy); -1 means "off" and is reported as-is. + AudioStreamIndex: currentAudioIndex, + SubtitleStreamIndex: currentSubtitleIndex, MediaSourceId: mediaSourceId, PositionTicks: msToTicks(progress.get()), IsPaused: !isPlaying, @@ -485,8 +488,8 @@ export default function DirectPlayerPage() { }, [ stream, item?.Id, - audioIndex, - subtitleIndex, + currentAudioIndex, + currentSubtitleIndex, mediaSourceId, progress, isPlaying, @@ -553,8 +556,8 @@ export default function DirectPlayerPage() { }, [ item?.Id, - audioIndex, - subtitleIndex, + currentAudioIndex, + currentSubtitleIndex, mediaSourceId, isPlaying, stream, @@ -1009,8 +1012,9 @@ export default function DirectPlayerPage() { subtitleIndex: defaultSubtitleIndex, } = getDefaultPlaySettings(previousItem, settings, { indexes: { - subtitleIndex: subtitleIndex, - audioIndex: audioIndex, + // Use the live selection, not the stale URL params (see goToNextItem). + subtitleIndex: currentSubtitleIndex, + audioIndex: currentAudioIndex, }, source: stream?.mediaSource ?? undefined, }); @@ -1029,8 +1033,8 @@ export default function DirectPlayerPage() { }, [ previousItem, settings, - subtitleIndex, - audioIndex, + currentSubtitleIndex, + currentAudioIndex, stream?.mediaSource, bitrateValue, router, @@ -1075,8 +1079,10 @@ export default function DirectPlayerPage() { subtitleIndex: defaultSubtitleIndex, } = getDefaultPlaySettings(nextItem, settings, { indexes: { - subtitleIndex: subtitleIndex, - audioIndex: audioIndex, + // Use the live selection (updated when the user changes tracks + // mid-playback), not the stale URL params the episode started with. + subtitleIndex: currentSubtitleIndex, + audioIndex: currentAudioIndex, }, source: stream?.mediaSource ?? undefined, }); @@ -1095,8 +1101,8 @@ export default function DirectPlayerPage() { }, [ nextItem, settings, - subtitleIndex, - audioIndex, + currentSubtitleIndex, + currentAudioIndex, stream?.mediaSource, bitrateValue, router, diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 2faecbaaf..07506bad0 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -233,14 +233,8 @@ export const Controls: FC = ({ const api = useAtomValue(apiAtom); const { settings } = useSettings(); const router = useRouter(); - const { - bitrateValue, - subtitleIndex: paramSubtitleIndex, - audioIndex: paramAudioIndex, - } = useLocalSearchParams<{ + const { bitrateValue } = useLocalSearchParams<{ bitrateValue: string; - subtitleIndex: string; - audioIndex: string; }>(); const { nextItem: internalNextItem } = usePlaybackManager({ @@ -583,10 +577,22 @@ export const Controls: FC = ({ const handleOpenSubtitleSheet = useCallback(() => { setLastOpenedModal("subtitle"); - // Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option - const tracksWithoutDisable = (videoContextSubtitleTracks ?? []).filter( - (track) => track.index !== -1, - ); + // Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option. + // Wrap each setTrack so selecting a subtitle ALSO updates the player's live + // index via onSubtitleIndexChange. The modal is a separate route, so the + // VideoContext router.setParams inside setTrack targets the modal — not the + // player — leaving currentSubtitleIndex stale. Without this sync, the next + // episode carries the previously-shown subtitle instead of the one the user + // just picked. (The audio sheet already uses onAudioIndexChange directly.) + const tracksWithoutDisable = (videoContextSubtitleTracks ?? []) + .filter((track) => track.index !== -1) + .map((track) => ({ + ...track, + setTrack: () => { + track.setTrack(); + onSubtitleIndexChange?.(track.index); + }, + })); showSubtitleModal({ item, mediaSourceId: mediaSource?.Id, @@ -598,6 +604,7 @@ export const Controls: FC = ({ (t) => t.index === -1, ); disableTrack?.setTrack(); + onSubtitleIndexChange?.(-1); }, onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded, refreshSubtitleTracks: onRefreshSubtitleTracks @@ -611,6 +618,7 @@ export const Controls: FC = ({ mediaSource?.Id, videoContextSubtitleTracks, subtitleIndex, + onSubtitleIndexChange, handleLocalSubtitleDownloaded, onRefreshSubtitleTracks, refreshSubtitleTracks, @@ -1031,13 +1039,12 @@ export const Controls: FC = ({ return; } + // Use the live selection passed down from the player (currentSubtitleIndex + // / currentAudioIndex), not the stale URL params the episode started with. + // This path runs on autoplay; the manual "Next" button uses goToNextItemProp. const previousIndexes = { - subtitleIndex: paramSubtitleIndex - ? Number.parseInt(paramSubtitleIndex, 10) - : undefined, - audioIndex: paramAudioIndex - ? Number.parseInt(paramAudioIndex, 10) - : undefined, + subtitleIndex, + audioIndex, }; const { @@ -1064,8 +1071,8 @@ export const Controls: FC = ({ [ nextItem, settings, - paramSubtitleIndex, - paramAudioIndex, + subtitleIndex, + audioIndex, mediaSource, bitrateValue, router, diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index bfbfb526a..b4da6b1e1 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -205,15 +205,19 @@ export function getDefaultPlaySettings( previous.indexes.subtitleIndex !== undefined ) { const ranker = new StreamRanker(new SubtitleStreamRanker()); - const result = { DefaultSubtitleStreamIndex: subtitleIndex }; + const result = { + DefaultSubtitleStreamIndex: subtitleIndex, + matched: false, + }; ranker.rankStream( previous.indexes.subtitleIndex, previous.source, streams, result, ); - // Check if StreamRanker found a match (changed from default) - if (result.DefaultSubtitleStreamIndex !== subtitleIndex) { + // Use the ranker's explicit match signal — this also covers a deliberate + // "subtitles off" (-1) and the case where the match equals the default. + if (result.matched) { subtitleIndex = result.DefaultSubtitleStreamIndex; matchedPreviousSubtitle = true; } @@ -224,15 +228,15 @@ export function getDefaultPlaySettings( previous.indexes.audioIndex !== undefined ) { const ranker = new StreamRanker(new AudioStreamRanker()); - const result = { DefaultAudioStreamIndex: audioIndex }; + const result = { DefaultAudioStreamIndex: audioIndex, matched: false }; ranker.rankStream( previous.indexes.audioIndex, previous.source, streams, result, ); - // Check if StreamRanker found a match (changed from default) - if (result.DefaultAudioStreamIndex !== audioIndex) { + // Use the ranker's explicit match signal + if (result.matched) { audioIndex = result.DefaultAudioStreamIndex; matchedPreviousAudio = true; } diff --git a/utils/streamRanker.ts b/utils/streamRanker.ts index 242cf950d..6e90cfc0e 100644 --- a/utils/streamRanker.ts +++ b/utils/streamRanker.ts @@ -13,6 +13,42 @@ abstract class StreamRankerStrategy { trackOptions: any, ): void; + /** + * Score how well a candidate stream matches the previously selected stream. + * Overridable so subtitle ranking can add mode (forced / hearing-impaired) + * awareness without changing audio behavior. + */ + protected computeScore( + prevStream: MediaStream, + stream: MediaStream, + prevRelIndex: number, + newRelIndex: number, + ): number { + let score = 0; + + if (prevStream.Codec === stream.Codec) { + score += 1; + } + if (prevRelIndex === newRelIndex) { + score += 1; + } + if ( + prevStream.DisplayTitle && + prevStream.DisplayTitle === stream.DisplayTitle + ) { + score += 2; + } + if ( + prevStream.Language && + prevStream.Language !== "und" && + prevStream.Language === stream.Language + ) { + score += 2; + } + + return score; + } + protected rank( prevIndex: number, prevSource: MediaSourceInfo, @@ -22,6 +58,9 @@ abstract class StreamRankerStrategy { if (prevIndex === -1) { console.debug("AutoSet Subtitle - No Stream Set"); trackOptions[`Default${this.streamType}StreamIndex`] = -1; + // A deliberate "off" selection is a valid match to retain — flag it so + // callers don't fall back to language preferences / subtitle mode. + trackOptions.matched = true; return; } @@ -63,27 +102,12 @@ abstract class StreamRankerStrategy { continue; } - let score = 0; - - if (prevStream.Codec === stream.Codec) { - score += 1; - } - if (prevRelIndex === newRelIndex) { - score += 1; - } - if ( - prevStream.DisplayTitle && - prevStream.DisplayTitle === stream.DisplayTitle - ) { - score += 2; - } - if ( - prevStream.Language && - prevStream.Language !== "und" && - prevStream.Language === stream.Language - ) { - score += 2; - } + const score = this.computeScore( + prevStream, + stream, + prevRelIndex, + newRelIndex, + ); console.debug( `AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`, @@ -101,6 +125,7 @@ abstract class StreamRankerStrategy { `AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`, ); trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex; + trackOptions.matched = true; } else { console.debug( `AutoSet ${this.streamType} - Threshold not met. Using default.`, @@ -112,6 +137,67 @@ abstract class StreamRankerStrategy { class SubtitleStreamRanker extends StreamRankerStrategy { streamType = "Subtitle"; + /** + * Subtitle scoring that retains both language and mode across episodes. + * + * - When the previous track has a language: a language match is weighted high + * (+3) so it clears the threshold even when codec / title / position differ, + * and mode (forced / hearing-impaired) acts as a tiebreaker among + * same-language tracks. Different-language candidates get no language or mode + * points, so they can never be selected on mode alone (no cross-language + * hijack). + * - When the previous track has NO usable language (common for SRT/SUBRIP): + * language can't help, so mode (forced / hearing-impaired) + codec + relative + * position become the identity signal. Without this, unlabeled subtitles + * score only codec+relIndex (≤2) and the selection is silently lost. + */ + protected computeScore( + prevStream: MediaStream, + stream: MediaStream, + prevRelIndex: number, + newRelIndex: number, + ): number { + let score = 0; + + if (prevStream.Codec === stream.Codec) { + score += 1; + } + if (prevRelIndex === newRelIndex) { + score += 1; + } + if ( + prevStream.DisplayTitle && + prevStream.DisplayTitle === stream.DisplayTitle + ) { + score += 2; + } + + const prevHasLanguage = + !!prevStream.Language && prevStream.Language !== "und"; + const languageMatches = + prevHasLanguage && prevStream.Language === stream.Language; + + if (languageMatches) { + score += 3; + } else if (prevHasLanguage) { + // Previous track had a language but this candidate's differs — do not award + // mode points, so a different language is never matched on mode alone. + return score; + } + + // Either the language matched, or the previous track had no language (so mode + // is the primary identity). Normalize the flags to booleans since + // IsForced / IsHearingImpaired may be undefined. + if (!!prevStream.IsForced === !!stream.IsForced) { + score += 2; + } + if (!!prevStream.IsHearingImpaired === !!stream.IsHearingImpaired) { + score += 1; + } + + return score; + } + rankStream( prevIndex: number, prevSource: MediaSourceInfo,