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.
This commit is contained in:
Fredrik Burmester
2026-05-30 20:15:24 +02:00
parent e044859aaf
commit 6876ce046f
4 changed files with 167 additions and 64 deletions

View File

@@ -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,

View File

@@ -233,14 +233,8 @@ export const Controls: FC<Props> = ({
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<Props> = ({
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<Props> = ({
(t) => t.index === -1,
);
disableTrack?.setTrack();
onSubtitleIndexChange?.(-1);
},
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
refreshSubtitleTracks: onRefreshSubtitleTracks
@@ -611,6 +618,7 @@ export const Controls: FC<Props> = ({
mediaSource?.Id,
videoContextSubtitleTracks,
subtitleIndex,
onSubtitleIndexChange,
handleLocalSubtitleDownloaded,
onRefreshSubtitleTracks,
refreshSubtitleTracks,
@@ -1031,13 +1039,12 @@ export const Controls: FC<Props> = ({
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<Props> = ({
[
nextItem,
settings,
paramSubtitleIndex,
paramAudioIndex,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,

View File

@@ -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;
}

View File

@@ -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,