Skip to content

Commit 467b049

Browse files
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
1 parent 87ccc59 commit 467b049

6 files changed

Lines changed: 158 additions & 3 deletions

File tree

src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@
4848
margin-left: 1rem;
4949
background-color: var(--secondary-accent-color);
5050
}
51+
52+
.secondary-icon {
53+
flex: none;
54+
width: 0.5rem;
55+
height: 0.5rem;
56+
border-radius: 100%;
57+
margin-left: 1rem;
58+
border: 2px solid var(--secondary-accent-color);
59+
background-color: transparent;
60+
}
5161
}
5262

5363
.context-menu-option {

src/routes/Player/SubtitlesMenu/SubtitlesMenu.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
7575
:
7676
null;
7777
}, [subtitlesTracks, extraSubtitlesTracks, props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId]);
78+
const selectedSecondarySubtitlesLanguage = React.useMemo(() => {
79+
return typeof props.selectedSecondarySubtitlesTrackId === 'string' ?
80+
allSubtitles
81+
.reduce((selected, { id, lang }) => {
82+
if (id === props.selectedSecondarySubtitlesTrackId) {
83+
return lang;
84+
}
85+
return selected;
86+
}, null)
87+
:
88+
null;
89+
}, [allSubtitles, props.selectedSecondarySubtitlesTrackId]);
7890
const subtitlesTracksForLanguage = React.useMemo(() => {
7991
const tracks = allSubtitles.filter(({ lang }) => lang === selectedSubtitlesLanguage);
8092
return sortByValues(tracks, ORIGIN_PRIORITIES);
@@ -153,6 +165,41 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
153165
}
154166
}
155167
}, [props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId, props.subtitlesOffset, props.extraSubtitlesOffset, props.onSubtitlesOffsetChanged, props.onExtraSubtitlesOffsetChanged]);
168+
169+
// Secondary subtitle support (desktop-only via ShellVideo/mpv)
170+
const hasPrimary = typeof props.selectedSubtitlesTrackId === 'string' ||
171+
typeof props.selectedExtraSubtitlesTrackId === 'string';
172+
const showSecondarySection = hasPrimary && props.isShellActive &&
173+
typeof props.onSecondarySubtitlesTrackSelected === 'function';
174+
175+
const secondaryLanguages = React.useMemo(() => {
176+
return subtitlesLanguages.filter((lang) => lang !== selectedSubtitlesLanguage);
177+
}, [subtitlesLanguages, selectedSubtitlesLanguage]);
178+
179+
const secondaryLanguageOnClick = React.useCallback((event) => {
180+
const lang = event.currentTarget.dataset.lang;
181+
// OFF button has no data-lang
182+
if (!lang) {
183+
if (typeof props.onSecondarySubtitlesTrackSelected === 'function') {
184+
props.onSecondarySubtitlesTrackSelected(null);
185+
}
186+
return;
187+
}
188+
189+
if (lang === selectedSecondarySubtitlesLanguage) {
190+
// Deselect secondary if clicking the already-selected one
191+
if (typeof props.onSecondarySubtitlesTrackSelected === 'function') {
192+
props.onSecondarySubtitlesTrackSelected(null);
193+
}
194+
return;
195+
}
196+
197+
const tracks = allSubtitles.filter(({ lang: tLang }) => tLang === lang);
198+
const track = sortByValues(tracks, ORIGIN_PRIORITIES).shift();
199+
if (track && typeof props.onSecondarySubtitlesTrackSelected === 'function') {
200+
props.onSecondarySubtitlesTrackSelected(track);
201+
}
202+
}, [allSubtitles, selectedSecondarySubtitlesLanguage, props.onSecondarySubtitlesTrackSelected]);
156203
return (
157204
<div ref={ref} className={classnames(props.className, styles['subtitles-menu-container'])} onMouseDown={onMouseDown}>
158205
<div className={styles['languages-container']}>
@@ -183,6 +230,40 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
183230
</Button>
184231
))}
185232
</div>
233+
{
234+
showSecondarySection && secondaryLanguages.length > 0 ?
235+
<React.Fragment>
236+
<div className={styles['secondary-header']}>{ t('PLAYER_SUBTITLES_SECONDARY', { defaultValue: 'Secondary Subtitle' }) }</div>
237+
<div className={styles['languages-list']}>
238+
<Button title={t('OFF')} className={classnames(styles['language-option'], { 'secondary-selected': selectedSecondarySubtitlesLanguage === null })} onClick={secondaryLanguageOnClick}>
239+
<div className={styles['language-label']}>{ t('OFF') }</div>
240+
{
241+
selectedSecondarySubtitlesLanguage === null ?
242+
<div className={styles['secondary-icon']} />
243+
:
244+
null
245+
}
246+
</Button>
247+
{secondaryLanguages.map((lang, index) => (
248+
<Button key={index} title={languages.label(lang)} className={classnames(styles['language-option'], { 'secondary-selected': selectedSecondarySubtitlesLanguage === lang })} data-lang={lang} onClick={secondaryLanguageOnClick}>
249+
<div className={styles['language-label']}>
250+
{
251+
lang === 'local' ? t('LOCAL') : languages.label(lang)
252+
}
253+
</div>
254+
{
255+
selectedSecondarySubtitlesLanguage === lang ?
256+
<div className={styles['secondary-icon']} />
257+
:
258+
null
259+
}
260+
</Button>
261+
))}
262+
</div>
263+
</React.Fragment>
264+
:
265+
null
266+
}
186267
</div>
187268
<div className={styles['variants-container']}>
188269
<div className={styles['variants-header']}>{ t('PLAYER_SUBTITLES_VARIANTS') }</div>
@@ -250,6 +331,7 @@ SubtitlesMenu.displayName = 'MainNavBars';
250331

251332
SubtitlesMenu.propTypes = {
252333
className: PropTypes.string,
334+
isShellActive: PropTypes.bool,
253335
subtitlesLanguage: PropTypes.string,
254336
interfaceLanguage: PropTypes.string,
255337
subtitlesTracks: PropTypes.arrayOf(PropTypes.shape({
@@ -258,6 +340,7 @@ SubtitlesMenu.propTypes = {
258340
origin: PropTypes.string.isRequired
259341
})),
260342
selectedSubtitlesTrackId: PropTypes.string,
343+
selectedSecondarySubtitlesTrackId: PropTypes.string,
261344
subtitlesOffset: PropTypes.number,
262345
subtitlesSize: PropTypes.number,
263346
extraSubtitlesTracks: PropTypes.arrayOf(PropTypes.shape({
@@ -276,6 +359,7 @@ SubtitlesMenu.propTypes = {
276359
extraSubtitlesSize: PropTypes.number,
277360
onSubtitlesTrackSelected: PropTypes.func,
278361
onExtraSubtitlesTrackSelected: PropTypes.func,
362+
onSecondarySubtitlesTrackSelected: PropTypes.func,
279363
onSubtitlesOffsetChanged: PropTypes.func,
280364
onSubtitlesSizeChanged: PropTypes.func,
281365
onExtraSubtitlesOffsetChanged: PropTypes.func,

src/routes/Player/SubtitlesMenu/styles.less

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,15 @@
3636
margin-bottom: 0.5rem;
3737
border-radius: var(--border-radius);
3838

39-
&:global(.selected), &:hover {
39+
&:global(.selected), &:global(.secondary-selected), &:hover {
4040
background-color: var(--overlay-color);
4141
}
4242

43+
&:global(.secondary-selected) {
44+
background-color: var(--overlay-color);
45+
opacity: 0.8;
46+
}
47+
4348
.language-label {
4449
flex: 1;
4550
font-size: 1.1rem;
@@ -56,12 +61,36 @@
5661
margin-left: 1rem;
5762
background-color: var(--secondary-accent-color);
5863
}
64+
65+
.secondary-icon {
66+
flex: none;
67+
width: 0.5rem;
68+
height: 0.5rem;
69+
border-radius: 100%;
70+
margin-left: 1rem;
71+
border: 2px solid var(--secondary-accent-color);
72+
background-color: transparent;
73+
}
5974
}
6075
}
6176
}
6277

6378
.languages-container {
6479
width: 16rem;
80+
81+
.secondary-header {
82+
flex: none;
83+
align-self: stretch;
84+
padding: 0.75rem 2rem 0.25rem;
85+
font-size: 0.9rem;
86+
font-weight: 700;
87+
color: var(--color-placeholder-text);
88+
text-transform: uppercase;
89+
letter-spacing: 0.05em;
90+
border-top: 1px solid var(--overlay-color);
91+
margin-top: 0.5rem;
92+
padding-top: 1rem;
93+
}
6594
}
6695

6796
.variants-container {

src/routes/Player/useSubtitles.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type VideoSubtitleState = {
2222
stream: unknown | null,
2323
subtitlesTracks: SubtitleTrack[],
2424
selectedSubtitlesTrackId: string | null,
25+
selectedSecondarySubtitlesTrackId: string | null,
2526
subtitlesOffset: number | null,
2627
subtitlesSize: number | null,
2728
extraSubtitlesTracks: SubtitleTrack[],
@@ -43,6 +44,7 @@ type VideoController = {
4344
addLocalSubtitles: (filename: string, buffer: ArrayBuffer) => void,
4445
setSubtitlesTrack: (id: string | null) => void,
4546
setExtraSubtitlesTrack: (id: string | null) => void,
47+
setSecondarySubtitlesTrack: (id: string | null) => void,
4648
setSubtitlesDelay: (delay: number) => void,
4749
setSubtitlesSize: (size: number) => void,
4850
setSubtitlesOffset: (offset: number) => void,
@@ -63,10 +65,12 @@ type UseSubtitlesArgs = {
6365
};
6466

6567
type SubtitlesMenuProps = {
68+
isShellActive: boolean,
6669
subtitlesLanguage: string | null,
6770
interfaceLanguage: string,
6871
subtitlesTracks: SubtitleTrack[],
6972
selectedSubtitlesTrackId: string | null,
73+
selectedSecondarySubtitlesTrackId: string | null,
7074
subtitlesOffset: number | null,
7175
subtitlesSize: number | null,
7276
extraSubtitlesTracks: SubtitleTrack[],
@@ -76,6 +80,7 @@ type SubtitlesMenuProps = {
7680
extraSubtitlesSize: number | null,
7781
onSubtitlesTrackSelected: (track: SubtitleTrack | null) => void,
7882
onExtraSubtitlesTrackSelected: (track: SubtitleTrack | null) => void,
83+
onSecondarySubtitlesTrackSelected: (track: SubtitleTrack | null) => void,
7984
onSubtitlesOffsetChanged: (offset: number) => void,
8085
onSubtitlesSizeChanged: (size: number) => void,
8186
onExtraSubtitlesOffsetChanged: (offset: number) => void,

src/routes/Player/useSubtitles.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useCallback, useEffect, useMemo, useRef } from 'react';
44
import { useTranslation } from 'react-i18next';
5-
import { CONSTANTS, languages, onFileDrop, onShortcut, useToast } from 'stremio/common';
5+
import { CONSTANTS, languages, onFileDrop, onShortcut, usePlatform, useToast } from 'stremio/common';
66

77
const withFallbackLabels = (tracks?: SubtitleTrack[] | null): SubtitleTrack[] => {
88
if (!Array.isArray(tracks)) {
@@ -47,6 +47,7 @@ const useSubtitles = ({
4747
}: UseSubtitlesArgs): UseSubtitlesResult => {
4848
const { t } = useTranslation();
4949
const toast = useToast();
50+
const platform = usePlatform();
5051
const videoRef = useRef(video);
5152
const settingsRef = useRef(settings);
5253
const defaultTrackSelected = useRef(false);
@@ -95,6 +96,7 @@ const useSubtitles = ({
9596
defaultTrackSelected.current = true;
9697
video.setSubtitlesTrack(null);
9798
video.setExtraSubtitlesTrack(null);
99+
video.setSecondarySubtitlesTrack(null);
98100
streamStateChanged({ subtitleTrack: null });
99101
}, [streamStateChanged, video]);
100102

@@ -120,6 +122,16 @@ const useSubtitles = ({
120122
rememberTrack(track, false);
121123
}, [disableSubtitles, rememberTrack, video]);
122124

125+
const selectSecondaryTrack = useCallback((track: SubtitleTrack | null) => {
126+
if (!track) {
127+
video.setSecondarySubtitlesTrack(null);
128+
return;
129+
}
130+
131+
defaultTrackSelected.current = true;
132+
video.setSecondarySubtitlesTrack(track.id);
133+
}, [video]);
134+
123135
const changeDelay = useCallback((delay: number) => {
124136
video.setSubtitlesDelay(delay);
125137
streamStateChanged({ subtitleDelay: delay });
@@ -301,7 +313,8 @@ const useSubtitles = ({
301313

302314
onShortcut('toggleSubtitles', () => {
303315
const subtitlesEnabled = video.state.selectedSubtitlesTrackId !== null ||
304-
video.state.selectedExtraSubtitlesTrackId !== null;
316+
video.state.selectedExtraSubtitlesTrackId !== null ||
317+
video.state.selectedSecondarySubtitlesTrackId !== null;
305318

306319
if (subtitlesEnabled) {
307320
if (video.state.selectedSubtitlesTrackId) {
@@ -318,6 +331,7 @@ const useSubtitles = ({
318331

319332
video.setSubtitlesTrack(null);
320333
video.setExtraSubtitlesTrack(null);
334+
video.setSecondarySubtitlesTrack(null);
321335
return;
322336
}
323337

@@ -332,6 +346,7 @@ const useSubtitles = ({
332346
player.streamState,
333347
video.state.selectedExtraSubtitlesTrackId,
334348
video.state.selectedSubtitlesTrackId,
349+
video.state.selectedSecondarySubtitlesTrackId,
335350
], !menusOpen);
336351

337352
onShortcut('subtitlesMenu', () => {
@@ -342,10 +357,12 @@ const useSubtitles = ({
342357
}, [closeMenus, hasTracks, toggleSubtitlesMenu]);
343358

344359
const menuProps = useMemo(() => ({
360+
isShellActive: platform.shell.active,
345361
subtitlesLanguage: settings.subtitlesLanguage,
346362
interfaceLanguage: settings.interfaceLanguage,
347363
subtitlesTracks: video.state.subtitlesTracks,
348364
selectedSubtitlesTrackId: video.state.selectedSubtitlesTrackId,
365+
selectedSecondarySubtitlesTrackId: video.state.selectedSecondarySubtitlesTrackId,
349366
subtitlesOffset: video.state.subtitlesOffset,
350367
subtitlesSize: video.state.subtitlesSize,
351368
extraSubtitlesTracks: video.state.extraSubtitlesTracks,
@@ -355,6 +372,7 @@ const useSubtitles = ({
355372
extraSubtitlesSize: video.state.extraSubtitlesSize,
356373
onSubtitlesTrackSelected: selectEmbeddedTrack,
357374
onExtraSubtitlesTrackSelected: selectExtraTrack,
375+
onSecondarySubtitlesTrackSelected: selectSecondaryTrack,
358376
onSubtitlesOffsetChanged: changeOffset,
359377
onSubtitlesSizeChanged: changeSize,
360378
onExtraSubtitlesOffsetChanged: changeOffset,
@@ -364,8 +382,10 @@ const useSubtitles = ({
364382
changeDelay,
365383
changeOffset,
366384
changeSize,
385+
platform.shell.active,
367386
selectEmbeddedTrack,
368387
selectExtraTrack,
388+
selectSecondaryTrack,
369389
settings.interfaceLanguage,
370390
settings.subtitlesLanguage,
371391
video.state.extraSubtitlesDelay,
@@ -374,6 +394,7 @@ const useSubtitles = ({
374394
video.state.extraSubtitlesTracks,
375395
video.state.selectedExtraSubtitlesTrackId,
376396
video.state.selectedSubtitlesTrackId,
397+
video.state.selectedSecondarySubtitlesTrackId,
377398
video.state.subtitlesOffset,
378399
video.state.subtitlesSize,
379400
video.state.subtitlesTracks,

src/routes/Player/useVideo.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const useVideo = () => {
3434
subtitlesOutlineColor: null,
3535
extraSubtitlesTracks: [],
3636
selectedExtraSubtitlesTrackId: null,
37+
selectedSecondarySubtitlesTrackId: null,
3738
extraSubtitlesSize: null,
3839
extraSubtitlesDelay: null,
3940
extraSubtitlesOffset: null,
@@ -130,6 +131,10 @@ const useVideo = () => {
130131
setProp('selectedExtraSubtitlesTrackId', id);
131132
};
132133

134+
const setSecondarySubtitlesTrack = (id) => {
135+
setProp('selectedSecondarySubtitlesTrackId', id);
136+
};
137+
133138
const setSubtitlesDelay = (delay) => {
134139
setProp('extraSubtitlesDelay', delay);
135140
};
@@ -248,6 +253,7 @@ const useVideo = () => {
248253
setSubtitlesBackgroundColor,
249254
setSubtitlesOutlineColor,
250255
setExtraSubtitlesTrack,
256+
setSecondarySubtitlesTrack,
251257
setVideoScale,
252258
setFullscreen,
253259
};

0 commit comments

Comments
 (0)