diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index 5dc4aba5c..5c0ec48a1 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -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_(); diff --git a/src/videojs-http-streaming.js b/src/videojs-http-streaming.js index 49df8d611..c5233da1f 100644 --- a/src/videojs-http-streaming.js +++ b/src/videojs-http-streaming.js @@ -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; } }; diff --git a/src/vtt-segment-loader.js b/src/vtt-segment-loader.js index 43be328a6..d87c7ded4 100644 --- a/src/vtt-segment-loader.js +++ b/src/vtt-segment-loader.js @@ -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. * @@ -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; @@ -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; } @@ -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 @@ -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 { diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index b979133b6..72367fc6d 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -1,4 +1,5 @@ import QUnit from 'qunit'; +import sinon from 'sinon'; import videojs from 'video.js'; import window from 'global/window'; import { @@ -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; diff --git a/test/videojs-http-streaming.test.js b/test/videojs-http-streaming.test.js index 4e6595e18..f1ee29f94 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -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(); @@ -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( diff --git a/test/vtt-segment-loader.test.js b/test/vtt-segment-loader.test.js index 5bd69d5ff..c25c9b0b3 100644 --- a/test/vtt-segment-loader.test.js +++ b/test/vtt-segment-loader.test.js @@ -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; @@ -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; @@ -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(); @@ -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; } ); @@ -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++); @@ -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) {