feat: preserve subtitle language across episodes

This commit is contained in:
Timothy Z. 2026-08-05 18:56:11 +03:00
parent 4c393caa45
commit 86fd6b0cf3
5 changed files with 248 additions and 81 deletions

View file

@ -21,7 +21,7 @@
"@babel/runtime": "7.29.2",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-preference/stremio-stremio-core-web-0.60.2.tgz",
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-language-preference/stremio-stremio-core-web-0.60.2.tgz",
"@stremio/stremio-icons": "5.10.0",
"@stremio/stremio-video": "0.0.90",
"a-color-picker": "1.2.1",

View file

@ -18,8 +18,8 @@ importers:
specifier: 5.2.0
version: 5.2.0
'@stremio/stremio-core-web':
specifier: https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-preference/stremio-stremio-core-web-0.60.2.tgz
version: https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-preference/stremio-stremio-core-web-0.60.2.tgz
specifier: https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-language-preference/stremio-stremio-core-web-0.60.2.tgz
version: https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-language-preference/stremio-stremio-core-web-0.60.2.tgz
'@stremio/stremio-icons':
specifier: 5.10.0
version: 5.10.0
@ -1417,8 +1417,8 @@ packages:
'@stremio/stremio-colors@5.2.0':
resolution: {integrity: sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg==}
'@stremio/stremio-core-web@https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-preference/stremio-stremio-core-web-0.60.2.tgz':
resolution: {integrity: sha512-ZIatR14rH7rB74EBR58qUb1f4z2M2PZk9S5jK0HBnr6dwLpcBIDbliN40S12IHn94GLixo4ixZgokvWRJyamHg==, tarball: https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-preference/stremio-stremio-core-web-0.60.2.tgz}
'@stremio/stremio-core-web@https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-language-preference/stremio-stremio-core-web-0.60.2.tgz':
resolution: {integrity: sha512-xb+xjClbnv7Bg9YprXh8bbxcfFXVoC17pGh4a3oGqkKTVyUJMERhLavaRhL9JZ6b5qmXY7dpkHg0sHZrA0pu/g==, tarball: https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-language-preference/stremio-stremio-core-web-0.60.2.tgz}
version: 0.60.2
'@stremio/stremio-icons@5.10.0':
@ -6483,7 +6483,7 @@ snapshots:
'@stremio/stremio-colors@5.2.0': {}
'@stremio/stremio-core-web@https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-preference/stremio-stremio-core-web-0.60.2.tgz':
'@stremio/stremio-core-web@https://stremio.github.io/stremio-core/stremio-core-web/feat/player-subtitle-language-preference/stremio-stremio-core-web-0.60.2.tgz':
dependencies:
'@babel/runtime': 7.24.1

View file

@ -47,6 +47,7 @@ type SubtitleSource = 'embedded' | 'external';
type SubtitlePreference = {
enabled: boolean,
source?: SubtitleSource,
language?: string,
};
type StreamState = {

View file

@ -16,6 +16,7 @@ type SubtitleTrack = {
type SelectedSubtitleTrack = {
id: string,
embedded: boolean,
language?: string,
};
type VideoSubtitleState = {

View file

@ -23,16 +23,139 @@ const findTrackById = (tracks: SubtitleTrack[], id?: string | null) => {
return tracks.find((track) => track.id === id);
};
const findTrackByLanguage = (tracks: SubtitleTrack[], language?: string | null) => {
const normalizeLanguage = (language?: string | null) => {
if (!language) {
return undefined;
}
const languageCode = languages.toCode(language);
const value = language.trim();
const normalized = languages.find(value) ?? languages.find(value.toLowerCase());
return tracks.find((track) => {
return track.lang === language || languages.toCode(track.lang) === languageCode;
return normalized?.code;
};
const findTrackByLanguage = (tracks: SubtitleTrack[], language?: string | null) => {
const languageCode = normalizeLanguage(language);
if (!languageCode) {
return undefined;
}
return tracks.find((track) => normalizeLanguage(track.lang) === languageCode);
};
type SubtitleCandidate = {
source: SubtitleSource,
id?: string,
language?: string,
};
type ResolvedSubtitleCandidate = {
source: SubtitleSource,
rank: number,
track: SubtitleTrack,
};
const candidateMatchesTrack = (
candidate: SubtitleCandidate,
source: SubtitleSource,
track: SubtitleTrack,
) => {
if (candidate.source !== source || (candidate.id && candidate.id !== track.id)) {
return false;
}
return !candidate.language || normalizeLanguage(track.lang) === candidate.language;
};
const resolveCandidate = (
candidate: SubtitleCandidate,
subtitlesTracks: SubtitleTrack[],
extraSubtitlesTracks: SubtitleTrack[],
) => {
const tracks = candidate.source === 'embedded' ? subtitlesTracks : extraSubtitlesTracks;
const track = candidate.id ?
findTrackById(tracks, candidate.id)
:
findTrackByLanguage(tracks, candidate.language);
return track && (!candidate.language || normalizeLanguage(track.lang) === candidate.language) ?
track
:
undefined;
};
const buildCandidates = (
sessionPreference: SubtitlePreference | null,
savedTrack: SubtitlesTrackState | null | undefined,
globalLanguage: string | null,
) => {
const candidates: SubtitleCandidate[] = [];
const languagesOrder: string[] = [];
const sessionEnabled = sessionPreference?.enabled === true;
const sessionLanguage = normalizeLanguage(sessionPreference?.language);
const savedLanguage = normalizeLanguage(savedTrack?.language);
const savedSource = savedTrack ? (savedTrack.embedded ? 'embedded' : 'external') : undefined;
const preferredSource = sessionEnabled ? sessionPreference.source : savedSource;
const sources: SubtitleSource[] = preferredSource === 'external' ?
['external', 'embedded']
:
['embedded', 'external'];
const addLanguage = (language?: string) => {
if (language && !languagesOrder.includes(language)) {
languagesOrder.push(language);
}
};
if (savedTrack?.id && (!sessionEnabled ||
!sessionPreference.source || sessionPreference.source === savedSource)) {
candidates.push({
source: savedSource as SubtitleSource,
id: savedTrack.id,
...(sessionLanguage ? { language: sessionLanguage } : {}),
});
}
if (sessionEnabled) {
addLanguage(sessionLanguage ?? savedLanguage);
} else {
addLanguage(savedLanguage);
}
addLanguage(normalizeLanguage(globalLanguage));
if (sessionEnabled) {
addLanguage(normalizeLanguage(CONSTANTS.DEFAULT_SUBTITLES_LANGUAGE));
}
// Keep language ahead of source so the selected language can cross source types.
languagesOrder.forEach((language) => {
sources.forEach((source) => candidates.push({ source, language }));
});
return candidates;
};
const resolveBestCandidate = (
candidates: SubtitleCandidate[],
subtitlesTracks: SubtitleTrack[],
extraSubtitlesTracks: SubtitleTrack[],
): ResolvedSubtitleCandidate | undefined => {
for (let rank = 0; rank < candidates.length; rank++) {
const candidate = candidates[rank];
const track = resolveCandidate(candidate, subtitlesTracks, extraSubtitlesTracks);
if (track) {
return { source: candidate.source, rank, track };
}
}
return undefined;
};
const findCandidateRank = (
candidates: SubtitleCandidate[],
source: SubtitleSource,
track: SubtitleTrack,
) => {
return candidates.findIndex((candidate) => candidateMatchesTrack(candidate, source, track));
};
const useSubtitles = ({
@ -50,7 +173,8 @@ const useSubtitles = ({
const toast = useToast();
const videoRef = useRef(video);
const settingsRef = useRef(settings);
const defaultTrackSelected = useRef(false);
const trackSelectionLocked = useRef(false);
const appliedTrack = useRef<{ id: string, source: SubtitleSource } | null>(null);
const lastSelectedTrack = useRef<SelectedSubtitleTrack | null>(null);
videoRef.current = video;
@ -69,7 +193,6 @@ const useSubtitles = ({
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks]);
const hasTracks = allTracks.length > 0;
const applySubtitleStyle = useCallback(() => {
const currentSettings = settingsRef.current;
const currentVideo = videoRef.current;
@ -82,7 +205,12 @@ const useSubtitles = ({
}, []);
const rememberTrack = useCallback((track: SubtitleTrack, embedded: boolean) => {
lastSelectedTrack.current = { id: track.id, embedded };
const language = normalizeLanguage(track.lang);
lastSelectedTrack.current = {
id: track.id,
embedded,
...(language ? { language } : {}),
};
streamStateChanged({
subtitleTrack: {
id: track.id,
@ -93,27 +221,36 @@ const useSubtitles = ({
subtitlePreferenceChanged({
enabled: true,
source: embedded ? 'embedded' : 'external',
...(language ? { language } : {}),
});
}, [streamStateChanged, subtitlePreferenceChanged]);
const disableSubtitles = useCallback(() => {
const source = video.state.selectedSubtitlesTrackId !== null ?
const selectedTrack = video.state.selectedSubtitlesTrackId !== null ?
findTrackById(video.state.subtitlesTracks, video.state.selectedSubtitlesTrackId)
:
findTrackById(video.state.extraSubtitlesTracks, video.state.selectedExtraSubtitlesTrackId);
const selectedSource = video.state.selectedSubtitlesTrackId !== null ?
'embedded'
:
video.state.selectedExtraSubtitlesTrackId !== null ?
'external'
:
player.subtitlePreference?.source;
undefined;
const source = player.subtitlePreference?.source ?? selectedSource;
const language = player.subtitlePreference?.language ?? normalizeLanguage(selectedTrack?.lang);
defaultTrackSelected.current = true;
trackSelectionLocked.current = true;
appliedTrack.current = null;
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
streamStateChanged({ subtitleTrack: null });
subtitlePreferenceChanged({
enabled: false,
...(source ? { source } : {}),
...(language ? { language } : {}),
});
}, [player.subtitlePreference?.source, streamStateChanged, subtitlePreferenceChanged, video]);
}, [player.subtitlePreference, streamStateChanged, subtitlePreferenceChanged, video]);
const selectEmbeddedTrack = useCallback((track: SubtitleTrack | null) => {
if (!track) {
@ -121,7 +258,8 @@ const useSubtitles = ({
return;
}
defaultTrackSelected.current = true;
trackSelectionLocked.current = true;
appliedTrack.current = { id: track.id, source: 'embedded' };
video.setSubtitlesTrack(track.id);
rememberTrack(track, true);
}, [disableSubtitles, rememberTrack, video]);
@ -132,7 +270,8 @@ const useSubtitles = ({
return;
}
defaultTrackSelected.current = true;
trackSelectionLocked.current = true;
appliedTrack.current = { id: track.id, source: 'external' };
video.setExtraSubtitlesTrack(track.id);
rememberTrack(track, false);
}, [disableSubtitles, rememberTrack, video]);
@ -179,81 +318,91 @@ const useSubtitles = ({
}, [externalSubtitles, video.state.stream]);
useEffect(() => {
defaultTrackSelected.current = false;
trackSelectionLocked.current = false;
appliedTrack.current = null;
lastSelectedTrack.current = null;
}, [video.state.stream]);
useEffect(() => {
if (defaultTrackSelected.current) {
if (trackSelectionLocked.current) {
return;
}
const sessionPreference = player.subtitlePreference;
const sessionEnabled = sessionPreference?.enabled === true;
const preferredSource = sessionEnabled ? sessionPreference.source : undefined;
if (sessionPreference?.enabled === false || (!sessionEnabled && settings.subtitlesLanguage === null)) {
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
defaultTrackSelected.current = true;
if (video.state.selectedSubtitlesTrackId !== null ||
video.state.selectedExtraSubtitlesTrackId !== null) {
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
}
appliedTrack.current = null;
return;
}
const savedTrack = player.streamState?.subtitleTrack;
const savedTrackId = savedTrack?.id;
const savedLanguage = savedTrack?.language;
const savedExternalTrack = Boolean(savedTrackId && savedTrack?.embedded === false);
const findDefaultTrack = (tracks: SubtitleTrack[], embedded: boolean) => {
if (savedTrackId && (preferredSource === undefined || savedTrack?.embedded === embedded)) {
return findTrackById(tracks, savedTrackId);
}
const candidates = buildCandidates(sessionPreference, savedTrack, settings.subtitlesLanguage);
const bestCandidate = resolveBestCandidate(
candidates,
video.state.subtitlesTracks,
video.state.extraSubtitlesTracks,
);
const selectedSource = video.state.selectedSubtitlesTrackId !== null ?
'embedded'
:
video.state.selectedExtraSubtitlesTrackId !== null ?
'external'
:
undefined;
const selectedTrack = selectedSource === 'embedded' ?
findTrackById(video.state.subtitlesTracks, video.state.selectedSubtitlesTrackId)
:
selectedSource === 'external' ?
findTrackById(video.state.extraSubtitlesTracks, video.state.selectedExtraSubtitlesTrackId)
:
undefined;
const language = savedLanguage ?? settings.subtitlesLanguage;
return findTrackByLanguage(tracks, language) ?? (sessionEnabled && !language ? tracks[0] : undefined);
if (!bestCandidate) {
if (sessionEnabled && selectedTrack) {
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
}
appliedTrack.current = null;
return;
}
const selectedRank = selectedTrack && selectedSource ?
findCandidateRank(candidates, selectedSource, selectedTrack)
:
-1;
const trackToApply = selectedRank === bestCandidate.rank && selectedTrack && selectedSource ?
{ source: selectedSource, track: selectedTrack }
:
bestCandidate;
// Reapply once per stream even if the controller already reports the same track.
if (appliedTrack.current?.id === trackToApply.track.id &&
appliedTrack.current.source === trackToApply.source) {
return;
}
trackToApply.source === 'embedded' ?
video.setSubtitlesTrack(trackToApply.track.id)
:
video.setExtraSubtitlesTrack(trackToApply.track.id);
appliedTrack.current = {
id: trackToApply.track.id,
source: trackToApply.source,
};
const embeddedTrack = findDefaultTrack(video.state.subtitlesTracks, true);
const extraTrack = findDefaultTrack(video.state.extraSubtitlesTracks, false);
if (preferredSource === 'external') {
if (extraTrack?.id) {
video.setExtraSubtitlesTrack(extraTrack.id);
defaultTrackSelected.current = true;
return;
}
if (embeddedTrack?.id && (video.state.selectedSubtitlesTrackId !== embeddedTrack.id ||
video.state.selectedExtraSubtitlesTrackId !== null)) {
video.setSubtitlesTrack(embeddedTrack.id);
}
return;
}
if (embeddedTrack?.id) {
video.setSubtitlesTrack(embeddedTrack.id);
defaultTrackSelected.current = true;
return;
}
if (extraTrack?.id) {
if (savedExternalTrack && preferredSource === undefined) {
video.setExtraSubtitlesTrack(extraTrack.id);
defaultTrackSelected.current = true;
return;
}
// Keep the first external match while waiting for an embedded track.
// Add-on subtitle results can arrive in stages and reorder `extraTrack`.
if (video.state.selectedExtraSubtitlesTrackId === null) {
video.setExtraSubtitlesTrack(extraTrack.id);
}
}
}, [
player.subtitlePreference,
player.streamState,
settings.subtitlesLanguage,
video.state.extraSubtitlesTracks,
video.state.selectedExtraSubtitlesTrackId,
video.state.selectedSubtitlesTrackId,
video.state.stream,
video.state.subtitlesTracks,
]);
@ -342,14 +491,26 @@ const useSubtitles = ({
if (subtitlesEnabled) {
if (video.state.selectedSubtitlesTrackId) {
const track = findTrackById(
video.state.subtitlesTracks,
video.state.selectedSubtitlesTrackId,
);
const language = normalizeLanguage(track?.lang);
lastSelectedTrack.current = {
id: video.state.selectedSubtitlesTrackId,
embedded: true,
...(language ? { language } : {}),
};
} else if (video.state.selectedExtraSubtitlesTrackId) {
const track = findTrackById(
video.state.extraSubtitlesTracks,
video.state.selectedExtraSubtitlesTrackId,
);
const language = normalizeLanguage(track?.lang);
lastSelectedTrack.current = {
id: video.state.selectedExtraSubtitlesTrackId,
embedded: false,
...(language ? { language } : {}),
};
}
@ -358,22 +519,26 @@ const useSubtitles = ({
}
const savedTrack = player.streamState?.subtitleTrack ?? lastSelectedTrack.current;
if (savedTrack?.id) {
subtitlePreferenceChanged({
enabled: true,
source: savedTrack.embedded ? 'embedded' : 'external',
});
savedTrack.embedded ?
video.setSubtitlesTrack(savedTrack.id)
:
video.setExtraSubtitlesTrack(savedTrack.id);
}
const source = player.subtitlePreference?.source ??
(savedTrack ? (savedTrack.embedded ? 'embedded' : 'external') : undefined);
const language = player.subtitlePreference?.language ?? normalizeLanguage(savedTrack?.language);
trackSelectionLocked.current = false;
appliedTrack.current = null;
subtitlePreferenceChanged({
enabled: true,
...(source ? { source } : {}),
...(language ? { language } : {}),
});
}, [
disableSubtitles,
player.subtitlePreference,
player.streamState,
subtitlePreferenceChanged,
video.state.extraSubtitlesTracks,
video.state.selectedExtraSubtitlesTrackId,
video.state.selectedSubtitlesTrackId,
video.state.subtitlesTracks,
], !menusOpen);
onShortcut('subtitlesMenu', () => {