From 467b049ef39b2bfb3bea3e31cf4eee5539e7ec2c Mon Sep 17 00:00:00 2001
From: Lakshay <10690117+lakshayrastogi@users.noreply.github.com>
Date: Thu, 21 May 2026 21:50:56 +0000
Subject: [PATCH] feat(player): add dual subtitle support for language learning
(desktop only)
Adds a "Secondary Subtitle" section to the player subtitles menu,
allowing users to select a second subtitle track that renders
simultaneously with the primary one. Gated to desktop only via
platform.shell.active since it relies on mpv's secondary-sid.
- Add setSecondarySubtitlesTrack to useVideo hook
- Add selectSecondaryTrack callback and menuProps in useSubtitles
- Build secondary subtitle section in SubtitlesMenu (appears when
primary selected + desktop shell active)
- Secondary section has own language list, click handlers, OFF button
- Hollow circle indicator for secondary vs filled for primary
- All existing subtitle selection behavior preserved (no interference)
- Platform guard: invisible on web, mobile, and TV platforms
- Translation: PLAYER_SUBTITLES_SECONDARY key with defaultValue fallback
---
.../SubtitleVariant/SubtitleVariant.less | 10 +++
.../Player/SubtitlesMenu/SubtitlesMenu.js | 84 +++++++++++++++++++
src/routes/Player/SubtitlesMenu/styles.less | 31 ++++++-
src/routes/Player/useSubtitles.d.ts | 5 ++
src/routes/Player/useSubtitles.ts | 25 +++++-
src/routes/Player/useVideo.js | 6 ++
6 files changed, 158 insertions(+), 3 deletions(-)
diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less
index 600b79658..b2901d7f1 100644
--- a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less
+++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less
@@ -48,6 +48,16 @@
margin-left: 1rem;
background-color: var(--secondary-accent-color);
}
+
+ .secondary-icon {
+ flex: none;
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 100%;
+ margin-left: 1rem;
+ border: 2px solid var(--secondary-accent-color);
+ background-color: transparent;
+ }
}
.context-menu-option {
diff --git a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js
index a71969bd5..57baf0fe1 100644
--- a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js
+++ b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js
@@ -75,6 +75,18 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
:
null;
}, [subtitlesTracks, extraSubtitlesTracks, props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId]);
+ const selectedSecondarySubtitlesLanguage = React.useMemo(() => {
+ return typeof props.selectedSecondarySubtitlesTrackId === 'string' ?
+ allSubtitles
+ .reduce((selected, { id, lang }) => {
+ if (id === props.selectedSecondarySubtitlesTrackId) {
+ return lang;
+ }
+ return selected;
+ }, null)
+ :
+ null;
+ }, [allSubtitles, props.selectedSecondarySubtitlesTrackId]);
const subtitlesTracksForLanguage = React.useMemo(() => {
const tracks = allSubtitles.filter(({ lang }) => lang === selectedSubtitlesLanguage);
return sortByValues(tracks, ORIGIN_PRIORITIES);
@@ -153,6 +165,41 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
}
}
}, [props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId, props.subtitlesOffset, props.extraSubtitlesOffset, props.onSubtitlesOffsetChanged, props.onExtraSubtitlesOffsetChanged]);
+
+ // Secondary subtitle support (desktop-only via ShellVideo/mpv)
+ const hasPrimary = typeof props.selectedSubtitlesTrackId === 'string' ||
+ typeof props.selectedExtraSubtitlesTrackId === 'string';
+ const showSecondarySection = hasPrimary && props.isShellActive &&
+ typeof props.onSecondarySubtitlesTrackSelected === 'function';
+
+ const secondaryLanguages = React.useMemo(() => {
+ return subtitlesLanguages.filter((lang) => lang !== selectedSubtitlesLanguage);
+ }, [subtitlesLanguages, selectedSubtitlesLanguage]);
+
+ const secondaryLanguageOnClick = React.useCallback((event) => {
+ const lang = event.currentTarget.dataset.lang;
+ // OFF button has no data-lang
+ if (!lang) {
+ if (typeof props.onSecondarySubtitlesTrackSelected === 'function') {
+ props.onSecondarySubtitlesTrackSelected(null);
+ }
+ return;
+ }
+
+ if (lang === selectedSecondarySubtitlesLanguage) {
+ // Deselect secondary if clicking the already-selected one
+ if (typeof props.onSecondarySubtitlesTrackSelected === 'function') {
+ props.onSecondarySubtitlesTrackSelected(null);
+ }
+ return;
+ }
+
+ const tracks = allSubtitles.filter(({ lang: tLang }) => tLang === lang);
+ const track = sortByValues(tracks, ORIGIN_PRIORITIES).shift();
+ if (track && typeof props.onSecondarySubtitlesTrackSelected === 'function') {
+ props.onSecondarySubtitlesTrackSelected(track);
+ }
+ }, [allSubtitles, selectedSecondarySubtitlesLanguage, props.onSecondarySubtitlesTrackSelected]);
return (
{ t('PLAYER_SUBTITLES_VARIANTS') }
@@ -250,6 +331,7 @@ SubtitlesMenu.displayName = 'MainNavBars';
SubtitlesMenu.propTypes = {
className: PropTypes.string,
+ isShellActive: PropTypes.bool,
subtitlesLanguage: PropTypes.string,
interfaceLanguage: PropTypes.string,
subtitlesTracks: PropTypes.arrayOf(PropTypes.shape({
@@ -258,6 +340,7 @@ SubtitlesMenu.propTypes = {
origin: PropTypes.string.isRequired
})),
selectedSubtitlesTrackId: PropTypes.string,
+ selectedSecondarySubtitlesTrackId: PropTypes.string,
subtitlesOffset: PropTypes.number,
subtitlesSize: PropTypes.number,
extraSubtitlesTracks: PropTypes.arrayOf(PropTypes.shape({
@@ -276,6 +359,7 @@ SubtitlesMenu.propTypes = {
extraSubtitlesSize: PropTypes.number,
onSubtitlesTrackSelected: PropTypes.func,
onExtraSubtitlesTrackSelected: PropTypes.func,
+ onSecondarySubtitlesTrackSelected: PropTypes.func,
onSubtitlesOffsetChanged: PropTypes.func,
onSubtitlesSizeChanged: PropTypes.func,
onExtraSubtitlesOffsetChanged: PropTypes.func,
diff --git a/src/routes/Player/SubtitlesMenu/styles.less b/src/routes/Player/SubtitlesMenu/styles.less
index b0aa3b051..1f173299c 100644
--- a/src/routes/Player/SubtitlesMenu/styles.less
+++ b/src/routes/Player/SubtitlesMenu/styles.less
@@ -36,10 +36,15 @@
margin-bottom: 0.5rem;
border-radius: var(--border-radius);
- &:global(.selected), &:hover {
+ &:global(.selected), &:global(.secondary-selected), &:hover {
background-color: var(--overlay-color);
}
+ &:global(.secondary-selected) {
+ background-color: var(--overlay-color);
+ opacity: 0.8;
+ }
+
.language-label {
flex: 1;
font-size: 1.1rem;
@@ -56,12 +61,36 @@
margin-left: 1rem;
background-color: var(--secondary-accent-color);
}
+
+ .secondary-icon {
+ flex: none;
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 100%;
+ margin-left: 1rem;
+ border: 2px solid var(--secondary-accent-color);
+ background-color: transparent;
+ }
}
}
}
.languages-container {
width: 16rem;
+
+ .secondary-header {
+ flex: none;
+ align-self: stretch;
+ padding: 0.75rem 2rem 0.25rem;
+ font-size: 0.9rem;
+ font-weight: 700;
+ color: var(--color-placeholder-text);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-top: 1px solid var(--overlay-color);
+ margin-top: 0.5rem;
+ padding-top: 1rem;
+ }
}
.variants-container {
diff --git a/src/routes/Player/useSubtitles.d.ts b/src/routes/Player/useSubtitles.d.ts
index 93084e7b2..c8e7fa5a1 100644
--- a/src/routes/Player/useSubtitles.d.ts
+++ b/src/routes/Player/useSubtitles.d.ts
@@ -22,6 +22,7 @@ type VideoSubtitleState = {
stream: unknown | null,
subtitlesTracks: SubtitleTrack[],
selectedSubtitlesTrackId: string | null,
+ selectedSecondarySubtitlesTrackId: string | null,
subtitlesOffset: number | null,
subtitlesSize: number | null,
extraSubtitlesTracks: SubtitleTrack[],
@@ -43,6 +44,7 @@ type VideoController = {
addLocalSubtitles: (filename: string, buffer: ArrayBuffer) => void,
setSubtitlesTrack: (id: string | null) => void,
setExtraSubtitlesTrack: (id: string | null) => void,
+ setSecondarySubtitlesTrack: (id: string | null) => void,
setSubtitlesDelay: (delay: number) => void,
setSubtitlesSize: (size: number) => void,
setSubtitlesOffset: (offset: number) => void,
@@ -63,10 +65,12 @@ type UseSubtitlesArgs = {
};
type SubtitlesMenuProps = {
+ isShellActive: boolean,
subtitlesLanguage: string | null,
interfaceLanguage: string,
subtitlesTracks: SubtitleTrack[],
selectedSubtitlesTrackId: string | null,
+ selectedSecondarySubtitlesTrackId: string | null,
subtitlesOffset: number | null,
subtitlesSize: number | null,
extraSubtitlesTracks: SubtitleTrack[],
@@ -76,6 +80,7 @@ type SubtitlesMenuProps = {
extraSubtitlesSize: number | null,
onSubtitlesTrackSelected: (track: SubtitleTrack | null) => void,
onExtraSubtitlesTrackSelected: (track: SubtitleTrack | null) => void,
+ onSecondarySubtitlesTrackSelected: (track: SubtitleTrack | null) => void,
onSubtitlesOffsetChanged: (offset: number) => void,
onSubtitlesSizeChanged: (size: number) => void,
onExtraSubtitlesOffsetChanged: (offset: number) => void,
diff --git a/src/routes/Player/useSubtitles.ts b/src/routes/Player/useSubtitles.ts
index 26de76d74..73a5bf4e4 100644
--- a/src/routes/Player/useSubtitles.ts
+++ b/src/routes/Player/useSubtitles.ts
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import { CONSTANTS, languages, onFileDrop, onShortcut, useToast } from 'stremio/common';
+import { CONSTANTS, languages, onFileDrop, onShortcut, usePlatform, useToast } from 'stremio/common';
const withFallbackLabels = (tracks?: SubtitleTrack[] | null): SubtitleTrack[] => {
if (!Array.isArray(tracks)) {
@@ -47,6 +47,7 @@ const useSubtitles = ({
}: UseSubtitlesArgs): UseSubtitlesResult => {
const { t } = useTranslation();
const toast = useToast();
+ const platform = usePlatform();
const videoRef = useRef(video);
const settingsRef = useRef(settings);
const defaultTrackSelected = useRef(false);
@@ -95,6 +96,7 @@ const useSubtitles = ({
defaultTrackSelected.current = true;
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
+ video.setSecondarySubtitlesTrack(null);
streamStateChanged({ subtitleTrack: null });
}, [streamStateChanged, video]);
@@ -120,6 +122,16 @@ const useSubtitles = ({
rememberTrack(track, false);
}, [disableSubtitles, rememberTrack, video]);
+ const selectSecondaryTrack = useCallback((track: SubtitleTrack | null) => {
+ if (!track) {
+ video.setSecondarySubtitlesTrack(null);
+ return;
+ }
+
+ defaultTrackSelected.current = true;
+ video.setSecondarySubtitlesTrack(track.id);
+ }, [video]);
+
const changeDelay = useCallback((delay: number) => {
video.setSubtitlesDelay(delay);
streamStateChanged({ subtitleDelay: delay });
@@ -301,7 +313,8 @@ const useSubtitles = ({
onShortcut('toggleSubtitles', () => {
const subtitlesEnabled = video.state.selectedSubtitlesTrackId !== null ||
- video.state.selectedExtraSubtitlesTrackId !== null;
+ video.state.selectedExtraSubtitlesTrackId !== null ||
+ video.state.selectedSecondarySubtitlesTrackId !== null;
if (subtitlesEnabled) {
if (video.state.selectedSubtitlesTrackId) {
@@ -318,6 +331,7 @@ const useSubtitles = ({
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
+ video.setSecondarySubtitlesTrack(null);
return;
}
@@ -332,6 +346,7 @@ const useSubtitles = ({
player.streamState,
video.state.selectedExtraSubtitlesTrackId,
video.state.selectedSubtitlesTrackId,
+ video.state.selectedSecondarySubtitlesTrackId,
], !menusOpen);
onShortcut('subtitlesMenu', () => {
@@ -342,10 +357,12 @@ const useSubtitles = ({
}, [closeMenus, hasTracks, toggleSubtitlesMenu]);
const menuProps = useMemo(() => ({
+ isShellActive: platform.shell.active,
subtitlesLanguage: settings.subtitlesLanguage,
interfaceLanguage: settings.interfaceLanguage,
subtitlesTracks: video.state.subtitlesTracks,
selectedSubtitlesTrackId: video.state.selectedSubtitlesTrackId,
+ selectedSecondarySubtitlesTrackId: video.state.selectedSecondarySubtitlesTrackId,
subtitlesOffset: video.state.subtitlesOffset,
subtitlesSize: video.state.subtitlesSize,
extraSubtitlesTracks: video.state.extraSubtitlesTracks,
@@ -355,6 +372,7 @@ const useSubtitles = ({
extraSubtitlesSize: video.state.extraSubtitlesSize,
onSubtitlesTrackSelected: selectEmbeddedTrack,
onExtraSubtitlesTrackSelected: selectExtraTrack,
+ onSecondarySubtitlesTrackSelected: selectSecondaryTrack,
onSubtitlesOffsetChanged: changeOffset,
onSubtitlesSizeChanged: changeSize,
onExtraSubtitlesOffsetChanged: changeOffset,
@@ -364,8 +382,10 @@ const useSubtitles = ({
changeDelay,
changeOffset,
changeSize,
+ platform.shell.active,
selectEmbeddedTrack,
selectExtraTrack,
+ selectSecondaryTrack,
settings.interfaceLanguage,
settings.subtitlesLanguage,
video.state.extraSubtitlesDelay,
@@ -374,6 +394,7 @@ const useSubtitles = ({
video.state.extraSubtitlesTracks,
video.state.selectedExtraSubtitlesTrackId,
video.state.selectedSubtitlesTrackId,
+ video.state.selectedSecondarySubtitlesTrackId,
video.state.subtitlesOffset,
video.state.subtitlesSize,
video.state.subtitlesTracks,
diff --git a/src/routes/Player/useVideo.js b/src/routes/Player/useVideo.js
index 570888ff1..bc26c79b9 100644
--- a/src/routes/Player/useVideo.js
+++ b/src/routes/Player/useVideo.js
@@ -34,6 +34,7 @@ const useVideo = () => {
subtitlesOutlineColor: null,
extraSubtitlesTracks: [],
selectedExtraSubtitlesTrackId: null,
+ selectedSecondarySubtitlesTrackId: null,
extraSubtitlesSize: null,
extraSubtitlesDelay: null,
extraSubtitlesOffset: null,
@@ -130,6 +131,10 @@ const useVideo = () => {
setProp('selectedExtraSubtitlesTrackId', id);
};
+ const setSecondarySubtitlesTrack = (id) => {
+ setProp('selectedSecondarySubtitlesTrackId', id);
+ };
+
const setSubtitlesDelay = (delay) => {
setProp('extraSubtitlesDelay', delay);
};
@@ -248,6 +253,7 @@ const useVideo = () => {
setSubtitlesBackgroundColor,
setSubtitlesOutlineColor,
setExtraSubtitlesTrack,
+ setSecondarySubtitlesTrack,
setVideoScale,
setFullscreen,
};