Skip to content

Commit

Permalink
fix: in-manifest VTT iOS MSE issue (#1364)
Browse files Browse the repository at this point in the history
* fix: Add exception guard for VTT parsing state if vtt.js is not loaded for any reasons.

* fix: Do not override native for all iOS/iPadOS browsers

* fix: Add guard for vtt-segment-loader to actually load vtt.js in case we do not have it loaded

* chore: fix eslit errors

* chore: Add loadVttJs test

* chore: Add test for parse exception if no vtt.js is loaded for any reason

* chore: fix typo

* chore: remove redundant default value

---------

Co-authored-by: Dzianis Dashkevich <[email protected]>
  • Loading branch information
dzianis-dashkevich and Dzianis Dashkevich committed Jan 30, 2023
1 parent 1ed5343 commit e735188
Show file tree
Hide file tree
Showing 6 changed files with 166 additions and 71 deletions.
19 changes: 18 additions & 1 deletion src/master-playlist-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,24 @@ export class MasterPlaylistController extends videojs.EventTarget {
this.subtitleSegmentLoader_ =
new VTTSegmentLoader(videojs.mergeOptions(segmentLoaderSettings, {
loaderType: 'vtt',
featuresNativeTextTracks: this.tech_.featuresNativeTextTracks
featuresNativeTextTracks: this.tech_.featuresNativeTextTracks,
loadVttJs: () => new Promise((resolve, reject) => {
function onLoad() {
tech.off('vttjserror', onError);
resolve();
}

function onError() {
tech.off('vttjsloaded', onLoad);
reject();
}

tech.one('vttjsloaded', onLoad);
tech.one('vttjserror', onError);

// safe to call multiple times, script will be loaded only once:
tech.addWebVttScript_();
})
}), options);

this.setupSegmentLoaderListeners_();
Expand Down
25 changes: 17 additions & 8 deletions src/videojs-http-streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -1286,17 +1286,26 @@ const VhsSourceHandler = {
tech.vhs.src(source.src, source.type);
return tech.vhs;
},
canPlayType(type, options = {}) {
const {
vhs: { overrideNative = !videojs.browser.IS_ANY_SAFARI } = {},
hls: { overrideNative: legacyOverrideNative = false } = {}
} = videojs.mergeOptions(videojs.options, options);
canPlayType(type, options) {
const simpleType = simpleTypeFromSourceType(type);

const supportedType = simpleTypeFromSourceType(type);
const canUseMsePlayback = supportedType &&
(!Vhs.supportsTypeNatively(supportedType) || legacyOverrideNative || overrideNative);
if (!simpleType) {
return '';
}

const overrideNative = VhsSourceHandler.getOverrideNative(options);
const supportsTypeNatively = Vhs.supportsTypeNatively(simpleType);
const canUseMsePlayback = !supportsTypeNatively || overrideNative;

return canUseMsePlayback ? 'maybe' : '';
},
getOverrideNative(options = {}) {
const { vhs = {}, hls = {} } = options;
const defaultOverrideNative = !(videojs.browser.IS_ANY_SAFARI || videojs.browser.IS_IOS);
const { overrideNative = defaultOverrideNative } = vhs;
const { overrideNative: legacyOverrideNative = false } = hls;

return legacyOverrideNative || overrideNative;
}
};

Expand Down
46 changes: 24 additions & 22 deletions src/vtt-segment-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import { ONE_SECOND_IN_TS } from 'mux.js/lib/utils/clock';
const VTT_LINE_TERMINATORS =
new Uint8Array('\n\n'.split('').map(char => char.charCodeAt(0)));

class NoVttJsError extends Error {
constructor() {
super('Trying to parse received VTT cues, but there is no WebVTT. Make sure vtt.js is loaded.');
}
}

/**
* An object that manages segment loading and appending.
*
Expand All @@ -34,6 +40,8 @@ export default class VTTSegmentLoader extends SegmentLoader {

this.featuresNativeTextTracks_ = settings.featuresNativeTextTracks;

this.loadVttJs = settings.loadVttJs;

// The VTT segment will have its own time mappings. Saving VTT segment timing info in
// the sync controller leads to improper behavior.
this.shouldSaveSegmentTimingInfo_ = false;
Expand Down Expand Up @@ -297,29 +305,16 @@ export default class VTTSegmentLoader extends SegmentLoader {
}
segmentInfo.bytes = simpleSegment.bytes;

// Make sure that vttjs has loaded, otherwise, wait till it finished loading
if (typeof window.WebVTT !== 'function' &&
this.subtitlesTrack_ &&
this.subtitlesTrack_.tech_) {

let loadHandler;
const errorHandler = () => {
this.subtitlesTrack_.tech_.off('vttjsloaded', loadHandler);
this.stopForError({
message: 'Error loading vtt.js'
});
return;
};

loadHandler = () => {
this.subtitlesTrack_.tech_.off('vttjserror', errorHandler);
this.segmentRequestFinished_(error, simpleSegment, result);
};

// Make sure that vttjs has loaded, otherwise, load it and wait till it finished loading
if (typeof window.WebVTT !== 'function' && typeof this.loadVttJs === 'function') {
this.state = 'WAITING_ON_VTTJS';
this.subtitlesTrack_.tech_.one('vttjsloaded', loadHandler);
this.subtitlesTrack_.tech_.one('vttjserror', errorHandler);

// should be fine to call multiple times
// script will be loaded once but multiple listeners will be added to the queue, which is expected.
this.loadVttJs()
.then(
() => this.segmentRequestFinished_(error, simpleSegment, result),
() => this.stopForError({ message: 'Error loading vtt.js' })
);
return;
}

Expand Down Expand Up @@ -391,6 +386,8 @@ export default class VTTSegmentLoader extends SegmentLoader {
/**
* Uses the WebVTT parser to parse the segment response
*
* @throws NoVttJsError
*
* @param {Object} segmentInfo
* a segment info object that describes the current segment
* @private
Expand All @@ -399,6 +396,11 @@ export default class VTTSegmentLoader extends SegmentLoader {
let decoder;
let decodeBytesToString = false;

if (typeof window.WebVTT !== 'function') {
// caller is responsible for exception handling.
throw new NoVttJsError();
}

if (typeof window.TextDecoder === 'function') {
decoder = new window.TextDecoder('utf8');
} else {
Expand Down
19 changes: 19 additions & 0 deletions test/master-playlist-controller.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import QUnit from 'qunit';
import sinon from 'sinon';
import videojs from 'video.js';
import window from 'global/window';
import {
Expand Down Expand Up @@ -618,6 +619,24 @@ QUnit.test('resets everything for a fast quality change', function(assert) {
assert.deepEqual(removeFuncArgs, {start: 0, end: 60}, 'remove() called with correct arguments if media is changed');
});

QUnit.test('loadVttJs should be passed to the vttSegmentLoader and resolved on vttjsloaded', function(assert) {
const stub = sinon.stub(this.player.tech_, 'addWebVttScript_').callsFake(() => this.player.tech_.trigger('vttjsloaded'));
const controller = new MasterPlaylistController({ src: 'test', tech: this.player.tech_});

controller.subtitleSegmentLoader_.loadVttJs().then(() => {
assert.equal(stub.callCount, 1, 'tech addWebVttScript called once');
});
});

QUnit.test('loadVttJs should be passed to the vttSegmentLoader and rejected on vttjserror', function(assert) {
const stub = sinon.stub(this.player.tech_, 'addWebVttScript_').callsFake(() => this.player.tech_.trigger('vttjserror'));
const controller = new MasterPlaylistController({ src: 'test', tech: this.player.tech_});

controller.subtitleSegmentLoader_.loadVttJs().catch(() => {
assert.equal(stub.callCount, 1, 'tech addWebVttScript called once');
});
});

QUnit.test('seeks in place for fast quality switch on non-IE/Edge browsers', function(assert) {
let seeks = 0;

Expand Down
7 changes: 5 additions & 2 deletions test/videojs-http-streaming.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3312,12 +3312,14 @@ QUnit.test('has no effect if native HLS is available and browser is Safari', fun
videojs.browser.IS_ANY_SAFARI = origIsAnySafari;
});

QUnit.test('loads if native HLS is available but browser is not Safari', function(assert) {
QUnit.test('has no effect if native HLS is available and browser is any non-safari browser on ios', function(assert) {
const Html5 = videojs.getTech('Html5');
const oldHtml5CanPlaySource = Html5.canPlaySource;
const origIsAnySafari = videojs.browser.IS_ANY_SAFARI;
const originalIsIos = videojs.browser.IS_IOS;

videojs.browser.IS_ANY_SAFARI = false;
videojs.browser.IS_IOS = true;
Html5.canPlaySource = () => true;
Vhs.supportsNativeHls = true;
const player = createPlayer();
Expand All @@ -3329,10 +3331,11 @@ QUnit.test('loads if native HLS is available but browser is not Safari', functio

this.clock.tick(1);

assert.ok(player.tech_.vhs, 'loaded VHS tech');
assert.ok(!player.tech_.vhs, 'did not load vhs tech');
player.dispose();
Html5.canPlaySource = oldHtml5CanPlaySource;
videojs.browser.IS_ANY_SAFARI = origIsAnySafari;
videojs.browser.IS_IOS = originalIsIos;
});

QUnit.test(
Expand Down
121 changes: 83 additions & 38 deletions test/vtt-segment-loader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
LoaderCommonFactory
} from './loader-common.js';
import { encryptionKey, subtitlesEncrypted } from 'create-test-data!segments';
import sinon from 'sinon';

const oldVTT = window.WebVTT;

Expand Down Expand Up @@ -308,6 +309,19 @@ QUnit.module('VTTSegmentLoader', function(hooks) {
QUnit.test(
'waits for vtt.js to be loaded before attempting to parse cues',
function(assert) {
let promiseLoadVttJs; let resolveLoadVttJs;

loader = new VTTSegmentLoader(LoaderCommonSettings.call(this, {
loaderType: 'vtt',
loadVttJs: () => {
promiseLoadVttJs = new Promise((resolve) => {
resolveLoadVttJs = resolve;
});

return promiseLoadVttJs;
}
}), {});

const vttjs = window.WebVTT;
const playlist = playlistWithDuration(40);
let parsedCues = false;
Expand All @@ -319,22 +333,6 @@ QUnit.module('VTTSegmentLoader', function(hooks) {
loader.state = 'READY';
};

let vttjsCallback = () => {};

this.track.tech_ = {
one(event, callback) {
if (event === 'vttjsloaded') {
vttjsCallback = callback;
}
},
trigger(event) {
if (event === 'vttjsloaded') {
vttjsCallback();
}
},
off() {}
};

loader.playlist(playlist);
loader.track(this.track);
loader.load();
Expand All @@ -361,10 +359,58 @@ QUnit.module('VTTSegmentLoader', function(hooks) {

window.WebVTT = vttjs;

loader.subtitlesTrack_.tech_.trigger('vttjsloaded');
promiseLoadVttJs.then(() => {
assert.equal(loader.state, 'READY', 'loader is ready to load next segment');
assert.ok(parsedCues, 'parsed cues');
});

assert.equal(loader.state, 'READY', 'loader is ready to load next segment');
assert.ok(parsedCues, 'parsed cues');
resolveLoadVttJs();
}
);

QUnit.test(
'parse should throw if no vtt.js is loaded for any reason',
function(assert) {
const vttjs = window.WebVTT;
const playlist = playlistWithDuration(40);
let errors = 0;

const originalParse = loader.parseVTTCues_.bind(loader);

loader.parseVTTCues_ = (...args) => {
delete window.WebVTT;
return originalParse(...args);
};

const spy = sinon.spy(loader, 'error');

loader.on('error', () => errors++);

loader.playlist(playlist);
loader.track(this.track);
loader.load();

assert.equal(errors, 0, 'no error at loader start');

this.clock.tick(1);

// state WAITING for segment response
this.requests[0].responseType = 'arraybuffer';
this.requests.shift().respond(200, null, new Uint8Array(10).buffer);

this.clock.tick(1);

assert.equal(errors, 1, 'triggered error when parser emmitts fatal error');
assert.ok(loader.paused(), 'loader paused when encountering fatal error');
assert.equal(loader.state, 'READY', 'loader reset after error');
assert.ok(
spy.withArgs(sinon.match({
message: 'Trying to parse received VTT cues, but there is no WebVTT. Make sure vtt.js is loaded.'
})).calledOnce,
'error method called once with instance of NoVttJsError'
);

window.WebVTT = vttjs;
}
);

Expand Down Expand Up @@ -748,25 +794,22 @@ QUnit.module('VTTSegmentLoader', function(hooks) {
});

QUnit.test('loader triggers error event when vtt.js fails to load', function(assert) {
let promiseLoadVttJs; let rejectLoadVttJs;

loader = new VTTSegmentLoader(LoaderCommonSettings.call(this, {
loaderType: 'vtt',
loadVttJs: () => {
promiseLoadVttJs = new Promise((resolve, reject) => {
rejectLoadVttJs = reject;
});

return promiseLoadVttJs;
}
}), {});
const playlist = playlistWithDuration(40);
let errors = 0;

delete window.WebVTT;
let vttjsCallback = () => {};

this.track.tech_ = {
one(event, callback) {
if (event === 'vttjserror') {
vttjsCallback = callback;
}
},
trigger(event) {
if (event === 'vttjserror') {
vttjsCallback();
}
},
off() {}
};

loader.on('error', () => errors++);

Expand Down Expand Up @@ -794,11 +837,13 @@ QUnit.module('VTTSegmentLoader', function(hooks) {
);
assert.equal(errors, 0, 'no errors yet');

loader.subtitlesTrack_.tech_.trigger('vttjserror');
promiseLoadVttJs.catch(() => {
assert.equal(loader.state, 'READY', 'loader is reset to ready');
assert.ok(loader.paused(), 'loader is paused after error');
assert.equal(errors, 1, 'loader triggered error when vtt.js load triggers error');
});

assert.equal(loader.state, 'READY', 'loader is reset to ready');
assert.ok(loader.paused(), 'loader is paused after error');
assert.equal(errors, 1, 'loader triggered error when vtt.js load triggers error');
rejectLoadVttJs();
});

QUnit.test('does not save segment timing info', function(assert) {
Expand Down

0 comments on commit e735188

Please sign in to comment.