mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 02:58:28 +01:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user