Skip to content

Commit 1e95245

Browse files
committed
Delegate ASS subtitles to native shells
1 parent 0386e37 commit 1e95245

2 files changed

Lines changed: 190 additions & 13 deletions

File tree

src/ShellVideo/ShellVideo.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,13 +289,18 @@ function ShellVideo(options) {
289289
props.subtitlesTracks = args.data
290290
.filter(function(x) { return x.type === 'sub'; })
291291
.map(function(x, index) {
292+
var codec = typeof x.codec === 'string' ? x.codec.toLowerCase() : null;
292293
return {
293294
id: 'EMBEDDED_' + x.id,
294295
lang: x.lang === undefined ? 'Track ' + (index + 1) : x.lang,
295296
label: x.title === undefined || x.lang === undefined ? '' : x.title || x.lang,
296297
origin: 'EMBEDDED',
297298
embedded: true,
298299
mode: x.id === props.sid ? 'showing' : 'disabled',
300+
ass: codec === 'ass' || codec === 'ssa',
301+
nativeExternal: x.external === true,
302+
nativeExternalFilename: x['external-filename'],
303+
nativeExternalTitle: x.title,
299304
};
300305
});
301306
onPropChanged('subtitlesTracks');

src/withHTMLSubtitles/withHTMLSubtitles.js

Lines changed: 185 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ var subtitleTypes = require('./subtitleTypes');
1111
var createASSRenderer = require('./assRenderer');
1212

1313
const PREVIEW_INTERVAL = 300000;
14+
const NATIVE_ASS_TIMEOUT = 10000;
1415

1516
function withHTMLSubtitles(Video) {
1617
function VideoWithHTMLSubtitles(options) {
@@ -140,6 +141,13 @@ function withHTMLSubtitles(Video) {
140141
var usingASSRenderer = false;
141142
var selectedSubtitleText = null;
142143
var selectionRequestId = 0;
144+
var nativeAssSubtitlesSupported = options.shellTransport &&
145+
options.shellTransport.capabilities &&
146+
options.shellTransport.capabilities.nativeAssSubtitles === true;
147+
var nativeAssTrackId = null;
148+
var pendingNativeAss = null;
149+
var abandonedNativeAssTitles = [];
150+
var removingNativeAssTrackIds = [];
143151

144152
var observedProps = {
145153
extraSubtitlesTracks: false,
@@ -268,6 +276,10 @@ function withHTMLSubtitles(Video) {
268276
videoAssSubtitlesStylingActive = propValue === true;
269277
break;
270278
}
279+
case 'subtitlesTracks': {
280+
updateNativeASSTrack(propValue);
281+
break;
282+
}
271283
case 'paused': {
272284
if (propValue && !videoState.paused && !videoState.buffering && videoState.lastSyncAt !== null && videoState.time !== null) {
273285
videoState.time = videoState.time + (Date.now() - videoState.lastSyncAt) * videoState.playbackSpeed;
@@ -302,7 +314,7 @@ function withHTMLSubtitles(Video) {
302314

303315
events.emit(eventName, propName, getProp(propName, propValue));
304316

305-
if (propName === 'selectedSubtitlesTrackId' && propValue !== null && selectedTrackId !== null && nativeTextTrack === null) {
317+
if (propName === 'selectedSubtitlesTrackId' && propValue !== null && selectedTrackId !== null && nativeTextTrack === null && !isNativeASSSelection(propValue)) {
306318
setProp('selectedExtraSubtitlesTrackId', null);
307319
}
308320
}
@@ -330,6 +342,95 @@ function withHTMLSubtitles(Video) {
330342
onPropChanged('assSubtitlesStylingActive');
331343
}
332344
}
345+
function getNativeASSRawTrackId(trackId) {
346+
return typeof trackId === 'string' && trackId.startsWith('EMBEDDED_') ?
347+
trackId.slice('EMBEDDED_'.length) :
348+
null;
349+
}
350+
function removeNativeASSTrack(trackId) {
351+
var rawTrackId = getNativeASSRawTrackId(trackId);
352+
if (rawTrackId !== null) {
353+
options.shellTransport.send('mpv-command', ['sub-remove', rawTrackId]);
354+
}
355+
}
356+
function clearNativeASS() {
357+
if (pendingNativeAss !== null) {
358+
clearTimeout(pendingNativeAss.timeout);
359+
if (!abandonedNativeAssTitles.includes(pendingNativeAss.title)) {
360+
abandonedNativeAssTitles.push(pendingNativeAss.title);
361+
}
362+
pendingNativeAss = null;
363+
}
364+
if (nativeAssTrackId !== null) {
365+
removeNativeASSTrack(nativeAssTrackId);
366+
nativeAssTrackId = null;
367+
}
368+
}
369+
function isNativeASSSelection(trackId) {
370+
return nativeAssTrackId === trackId || pendingNativeAss !== null;
371+
}
372+
function getPublicSubtitleTrack(track) {
373+
var publicTrack = Object.assign({}, track);
374+
delete publicTrack.nativeExternal;
375+
delete publicTrack.nativeExternalFilename;
376+
delete publicTrack.nativeExternalTitle;
377+
return publicTrack;
378+
}
379+
function isManagedNativeASSTrack(track) {
380+
return track.id === nativeAssTrackId ||
381+
removingNativeAssTrackIds.includes(track.id) ||
382+
(pendingNativeAss !== null && track.nativeExternalTitle === pendingNativeAss.title) ||
383+
abandonedNativeAssTitles.includes(track.nativeExternalTitle);
384+
}
385+
function updateNativeASSTrack(subtitleTracks) {
386+
if (!Array.isArray(subtitleTracks)) {
387+
return;
388+
}
389+
390+
removingNativeAssTrackIds = removingNativeAssTrackIds.filter(function(trackId) {
391+
return subtitleTracks.some(function(track) { return track.id === trackId; });
392+
});
393+
abandonedNativeAssTitles = abandonedNativeAssTitles.filter(function(title) {
394+
var abandonedTracks = subtitleTracks.filter(function(track) {
395+
return track.nativeExternalTitle === title;
396+
});
397+
abandonedTracks.forEach(function(track) {
398+
removingNativeAssTrackIds.push(track.id);
399+
removeNativeASSTrack(track.id);
400+
});
401+
return abandonedTracks.length === 0;
402+
});
403+
404+
if (pendingNativeAss === null) {
405+
return;
406+
}
407+
var matchingTrack = subtitleTracks.slice().reverse().find(function(track) {
408+
return track.nativeExternalTitle === pendingNativeAss.title;
409+
});
410+
if (!matchingTrack) {
411+
return;
412+
}
413+
414+
var loadedTrack = pendingNativeAss.track;
415+
clearTimeout(pendingNativeAss.timeout);
416+
pendingNativeAss = null;
417+
nativeAssTrackId = matchingTrack.id;
418+
video.dispatch({
419+
type: 'setProp',
420+
propName: 'subtitlesDelay',
421+
propValue: (delay || 0) / 1000,
422+
});
423+
events.emit('extraSubtitlesTrackLoaded', loadedTrack);
424+
}
425+
function updateTrackASS(track, isASS) {
426+
var trackIndex = tracks.findIndex(function(candidate) {
427+
return candidate.id === track.id;
428+
});
429+
if (trackIndex !== -1 && tracks[trackIndex].ass !== isASS) {
430+
tracks[trackIndex] = Object.assign({}, tracks[trackIndex], { ass: isASS });
431+
onPropChanged('extraSubtitlesTracks');
432+
}
433+
}
333434
function reportSubtitleError(error, track) {
334435
onError(Object.assign({}, ERROR.WITH_HTML_SUBTITLES.LOAD_FAILED, {
335436
error: error,
@@ -429,7 +530,43 @@ function withHTMLSubtitles(Video) {
429530
return fallbackFromASS(error, track, text, currentRequestId);
430531
});
431532
}
432-
function loadSelectedTrack(track, isFallback, currentRequestId) {
533+
function loadWebSubtitles(track, text, isASS, currentRequestId) {
534+
return isASS && assSubtitlesStylingEnabled ?
535+
loadASSSubtitles(track, text, currentRequestId) :
536+
loadPlainSubtitles(track, text, isASS, currentRequestId);
537+
}
538+
function delegateNativeASS(track, url, text, currentRequestId) {
539+
var title = '__stremio_external_ass_' + currentRequestId;
540+
var pending = {
541+
track: track,
542+
title: title,
543+
timeout: null,
544+
};
545+
pending.timeout = setTimeout(function() {
546+
if (pendingNativeAss !== pending ||
547+
currentRequestId !== selectionRequestId ||
548+
selectedTrackId !== track.id) {
549+
return;
550+
}
551+
552+
pendingNativeAss = null;
553+
abandonedNativeAssTitles.push(title);
554+
if (typeof text === 'string') {
555+
loadWebSubtitles(track, text, true, currentRequestId);
556+
} else {
557+
loadSelectedTrack(track, false, currentRequestId, true);
558+
}
559+
}, NATIVE_ASS_TIMEOUT);
560+
pendingNativeAss = pending;
561+
options.shellTransport.send('mpv-command', [
562+
'sub-add',
563+
url,
564+
'select',
565+
title,
566+
track.lang,
567+
]);
568+
}
569+
function loadSelectedTrack(track, isFallback, currentRequestId, forceWeb) {
433570
return getSubtitlesData(track, isFallback)
434571
.then(function(text) {
435572
if (currentRequestId !== selectionRequestId || selectedTrackId !== track.id) {
@@ -442,16 +579,19 @@ function withHTMLSubtitles(Video) {
442579
fallbackUrl: null
443580
});
444581
var isASS = subtitleTypes.isASSSubtitle(sourceTrack, text);
445-
return isASS && assSubtitlesStylingEnabled ?
446-
loadASSSubtitles(track, text, currentRequestId) :
447-
loadPlainSubtitles(track, text, isASS, currentRequestId);
582+
updateTrackASS(track, isASS);
583+
if (!forceWeb && nativeAssSubtitlesSupported && isASS && typeof sourceTrack.url === 'string') {
584+
delegateNativeASS(track, sourceTrack.url, text, currentRequestId);
585+
return null;
586+
}
587+
return loadWebSubtitles(track, text, isASS, currentRequestId);
448588
})
449589
.catch(function(error) {
450590
if (currentRequestId !== selectionRequestId || selectedTrackId !== track.id) {
451591
return null;
452592
}
453593
if (!isFallback && typeof track.fallbackUrl === 'string') {
454-
return loadSelectedTrack(track, true, currentRequestId);
594+
return loadSelectedTrack(track, true, currentRequestId, forceWeb);
455595
}
456596

457597
reportSubtitleError(error, track);
@@ -536,16 +676,25 @@ function withHTMLSubtitles(Video) {
536676
return assSubtitlesStylingActive || videoAssSubtitlesStylingActive;
537677
}
538678
case 'subtitlesTracks': {
539-
if (Array.isArray(videoPropValue) && videoElement && videoElement.textTracks) {
679+
if (Array.isArray(videoPropValue)) {
540680
return videoPropValue.filter(function(track) {
681+
if (isManagedNativeASSTrack(track)) {
682+
return false;
683+
}
684+
if (!videoElement || !videoElement.textTracks) {
685+
return true;
686+
}
541687
var index = getEmbeddedTrackIndex(track.id);
542688
return index === null || !isNativeTextTrack(videoElement.textTracks[index]);
543-
});
689+
}).map(getPublicSubtitleTrack);
544690
}
545691

546692
return videoPropValue;
547693
}
548694
case 'selectedSubtitlesTrackId': {
695+
if (isNativeASSSelection(videoPropValue)) {
696+
return null;
697+
}
549698
if (typeof videoPropValue === 'string' && videoElement && videoElement.textTracks) {
550699
var index = getEmbeddedTrackIndex(videoPropValue);
551700
return index !== null && isNativeTextTrack(videoElement.textTracks[index]) ? null : videoPropValue;
@@ -591,16 +740,17 @@ function withHTMLSubtitles(Video) {
591740
function setProp(propName, propValue) {
592741
switch (propName) {
593742
case 'selectedExtraSubtitlesTrackId': {
743+
if (propValue !== null && selectedTrackId === propValue) {
744+
return true;
745+
}
746+
clearNativeASS();
594747
if (propValue !== null) {
595748
video.dispatch({
596749
type: 'setProp',
597750
propName: 'selectedSubtitlesTrackId',
598751
propValue: null,
599752
});
600753
}
601-
if (propValue !== null && selectedTrackId === propValue) {
602-
return true;
603-
}
604754
var currentRequestId = ++selectionRequestId;
605755
cuesByTime = null;
606756
selectedTrackId = null;
@@ -621,7 +771,14 @@ function withHTMLSubtitles(Video) {
621771
if (selectedTrack) {
622772
selectedTrackId = selectedTrack.id;
623773
delay = 0;
624-
loadSelectedTrack(selectedTrack, false, currentRequestId);
774+
var selectedSourceIsASS = subtitleTypes.isASSSubtitleTrack(Object.assign({}, selectedTrack, {
775+
fallbackUrl: null,
776+
}));
777+
if (nativeAssSubtitlesSupported && selectedSourceIsASS && typeof selectedTrack.url === 'string') {
778+
delegateNativeASS(selectedTrack, selectedTrack.url, null, currentRequestId);
779+
} else {
780+
loadSelectedTrack(selectedTrack, false, currentRequestId, false);
781+
}
625782
}
626783
renderSubtitles();
627784
onPropChanged('selectedExtraSubtitlesTrackId');
@@ -632,6 +789,13 @@ function withHTMLSubtitles(Video) {
632789
if (selectedTrackId !== null && propValue !== null && isFinite(propValue)) {
633790
delay = parseInt(propValue, 10);
634791
assRenderer.setDelay(delay);
792+
if (nativeAssTrackId !== null || pendingNativeAss !== null) {
793+
video.dispatch({
794+
type: 'setProp',
795+
propName: 'subtitlesDelay',
796+
propValue: delay / 1000,
797+
});
798+
}
635799
forceRender = true;
636800
renderSubtitles();
637801
onPropChanged('extraSubtitlesDelay');
@@ -734,7 +898,11 @@ function withHTMLSubtitles(Video) {
734898
case 'addExtraSubtitlesTracks': {
735899
if (commandArgs && Array.isArray(commandArgs.tracks)) {
736900
tracks = tracks
737-
.concat(commandArgs.tracks)
901+
.concat(commandArgs.tracks.map(function(track) {
902+
return Object.assign({}, track, {
903+
ass: subtitleTypes.isASSSubtitleTrack(track),
904+
});
905+
}))
738906
.filter(function(track, index, tracks) {
739907
return track &&
740908
typeof track.id === 'string' &&
@@ -765,6 +933,7 @@ function withHTMLSubtitles(Video) {
765933
origin: 'LOCAL',
766934
local: true,
767935
embedded: false,
936+
ass: subtitleTypes.isASSSubtitleTrack({ filename: commandArgs.filename }),
768937
};
769938

770939
tracks.push(track);
@@ -794,6 +963,9 @@ function withHTMLSubtitles(Video) {
794963
}
795964
case 'unload': {
796965
selectionRequestId = selectionRequestId + 1;
966+
clearNativeASS();
967+
abandonedNativeAssTitles = [];
968+
removingNativeAssTrackIds = [];
797969
removeNativeTrack();
798970
stopRenderLoop();
799971
lastTimeIndex = null;

0 commit comments

Comments
 (0)