import document from 'global/document'; import videojs from 'video.js'; import Events from 'video.js'; import QUnit from 'qunit'; import testDataManifests from 'create-test-data!manifests'; import { muxed as muxedSegment, encryptionKey, encrypted as encryptedSegment, audio as audioSegment, video as videoSegment, mp4VideoInit as mp4VideoInitSegment, mp4Video as mp4VideoSegment, mp4AudioInit as mp4AudioInitSegment, mp4Audio as mp4AudioSegment } from 'create-test-data!segments'; import { useFakeEnvironment, useFakeMediaSource, createPlayer, openMediaSource, standardXHRResponse, absoluteUrl, requestAndAppendSegment, disposePlaybackWatcher } from './test-helpers.js'; import { createPlaylistID, parseManifest } from '../src/manifest.js'; /* eslint-disable no-unused-vars */ // we need this so that it can register vhs with videojs import { VhsSourceHandler, VhsHandler, Vhs, emeKeySystems, LOCAL_STORAGE_KEY, expandDataUri, setupEmeOptions, getAllPsshKeySystemsOptions, waitForKeySessionCreation } from '../src/videojs-http-streaming'; import window from 'global/window'; // we need this so the plugin registers itself import 'videojs-contrib-quality-levels'; import 'videojs-contrib-eme'; import {merge, createTimeRanges} from '../src/util/vjs-compat'; import {version as vhsVersion} from '../package.json'; import {version as muxVersion} from 'mux.js/package.json'; import {version as mpdVersion} from 'mpd-parser/package.json'; import {version as m3u8Version} from 'm3u8-parser/package.json'; import {version as aesVersion} from 'aes-decrypter/package.json'; const ogVhsHandlerSetupQualityLevels = videojs.VhsHandler.prototype.setupQualityLevels_; // do a shallow copy of the properties of source onto the target object const mergeShallow = function(target, source) { let name; for (name in source) { target[name] = source[name]; } }; QUnit.module('VHS', { beforeEach(assert) { this.env = useFakeEnvironment(assert); this.requests = this.env.requests; this.mse = useFakeMediaSource(); this.clock = this.env.clock; this.old = {}; this.old.devicePixelRatio = window.devicePixelRatio; window.devicePixelRatio = 1; // store functionality that some tests need to mock this.old.GlobalOptions = merge(videojs.options); // force the HLS tech to run this.old.NativeHlsSupport = videojs.Vhs.supportsNativeHls; videojs.Vhs.supportsNativeHls = false; this.old.NativeDashSupport = videojs.Vhs.supportsNativeDash; videojs.Vhs.supportsNativeDash = false; this.old.Decrypt = videojs.Vhs.Decrypter; videojs.Vhs.Decrypter = function() {}; // save and restore browser detection for the Firefox-specific tests this.old.browser = videojs.browser; videojs.browser = merge({}, videojs.browser); this.standardXHRResponse = (request, data) => { standardXHRResponse(request, data); // Because SegmentLoader#fillBuffer_ is now scheduled asynchronously // we have to use clock.tick to get the expected side effects of // SegmentLoader#handleAppendsDone_ this.clock.tick(1); }; // setup a player this.player = createPlayer(); this.clock.tick(1); }, afterEach() { this.env.restore(); this.mse.restore(); if (this.old.hasOwnProperty('devicePixelRatio')) { window.devicePixelRatio = this.old.devicePixelRatio; } // This seems duplicative of `merge` but tests fail if `merge` is used... mergeShallow(videojs.options, this.old.GlobalOptions); videojs.Vhs.supportsNativeHls = this.old.NativeHlsSupport; videojs.Vhs.supportsNativeDash = this.old.NativeDashSupport; videojs.Vhs.Decrypter = this.old.Decrypt; videojs.browser = this.old.browser; window.localStorage.clear(); this.player.dispose(); } }); QUnit.test('mse urls are created and revoked', function(assert) { const old = { createObjectURL: window.URL.createObjectURL, revokeObjectURL: window.URL.revokeObjectURL }; const ids = []; window.URL.createObjectURL = (...args) => { const id = old.createObjectURL.apply(window.URL, args); ids.push(id); return id; }; window.URL.revokeObjectURL = (...args) => { const index = ids.indexOf(args[0]); if (index !== -1) { ids.splice(index, 1); } return old.revokeObjectURL.apply(window.URL, args); }; this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); assert.ok(ids.length > 0, 'object urls created'); this.player.dispose(); assert.equal(ids.length, 0, 'all object urls removed'); window.URL.createObjectURL = old.createObjectURL; window.URL.revokeObjectURL = old.revokeObjectURL; }); QUnit.test('version is exported', function(assert) { this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); assert.ok(this.player.tech(true).vhs.version, 'version function'); assert.ok(videojs.VhsHandler.version, 'version function'); assert.deepEqual(this.player.tech(true).vhs.version(), { '@videojs/http-streaming': vhsVersion, 'mux.js': muxVersion, 'mpd-parser': mpdVersion, 'm3u8-parser': m3u8Version, 'aes-decrypter': aesVersion }, 'version is correct'); }); QUnit.test('canChangeType is exported', function(assert) { this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); assert.ok(this.player.tech(true).vhs.canChangeType, 'canChangeType function'); const canChangeType = window.SourceBuffer && window.SourceBuffer.prototype && typeof window.SourceBuffer.prototype.changeType === 'function'; const assertion = canChangeType ? 'ok' : 'notOk'; assert[assertion](this.player.tech(true).vhs.canChangeType(), 'canChangeType is correct'); }); QUnit.test('tech error may pause loading', function(assert) { this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); const vhs = this.player.tech_.vhs; const pc = vhs.playlistController_; let pauseCalled = false; pc.pauseLoading = () => { pauseCalled = true; }; this.player.tech_.error = () => null; this.player.tech_.trigger('error'); assert.notOk(pauseCalled, 'no video el error attribute, no pause loading'); this.player.tech_.error = () => 'foo'; this.player.tech_.trigger('error'); assert.ok(pauseCalled, 'video el error and trigger pauses loading'); assert.equal(this.env.log.error.calls, 1, '1 media error logged'); this.env.log.error.reset(); }); QUnit.test('VhsHandler is referenced by player.tech().vhs', function(assert) { this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); assert.ok( this.player.tech().vhs instanceof VhsHandler, 'player.tech().vhs references an instance of VhsHandler' ); assert.equal(this.env.log.warn.calls, 1, 'warning logged'); assert.equal( this.env.log.warn.args[0][0], 'Using the tech directly can be dangerous. I hope you know what you\'re doing.\n' + 'See https://github.com/videojs/video.js/issues/2617 for more info.\n', 'logged warning' ); }); QUnit.test('starts playing if autoplay is specified', function(assert) { this.player.autoplay(true); this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); // make sure play() is called *after* the media source opens openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests[0]); assert.ok(!this.player.paused(), 'not paused'); }); QUnit.test('stats are reset on each new source', function(assert) { const done = assert.async(); this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); // make sure play() is called *after* the media source opens openMediaSource(this.player, this.clock); const segment = muxedSegment(); // copy the byte length since the segment bytes get cleared out const segmentByteLength = segment.byteLength; assert.ok(segmentByteLength, 'the segment has some number of bytes'); // media this.standardXHRResponse(this.requests.shift()); this.player.tech(true).vhs.playlistController_.mainSegmentLoader_.one('appending', () => { assert.equal( this.player.tech_.vhs.stats.mediaBytesTransferred, segmentByteLength, 'stat is set' ); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); assert.equal(this.player.tech_.vhs.stats.mediaBytesTransferred, 0, 'stat is reset'); done(); }); // segment 0 this.standardXHRResponse(this.requests.shift(), segment); }); QUnit.test('XHR requests first byte range on play', function(assert) { this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.triggerReady(); this.clock.tick(1); this.player.tech_.trigger('play'); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests[0]); assert.equal(this.requests[1].headers.Range, 'bytes=0-522827'); }); QUnit.test('Seeking requests correct byte range', function(assert) { this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.trigger('play'); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests[0]); this.clock.tick(1); this.player.currentTime(41); this.clock.tick(2); assert.equal(this.requests[2].headers.Range, 'bytes=2299992-2835603'); }); QUnit.test('autoplay seeks to the live point after playlist load', function(assert) { let currentTime = 0; this.player.autoplay(true); this.player.tech_.currentTime = (ct) => { currentTime = ct; }; this.player.src({ src: 'liveStart30sBefore.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.readyState = () => 4; this.player.tech_.trigger('play'); this.standardXHRResponse(this.requests.shift()); this.clock.tick(1); assert.notEqual(currentTime, 0, 'seeked on autoplay'); }); QUnit.test('autoplay seeks to the live point after media source open', function(assert) { let currentTime = 0; this.player.autoplay(true); this.player.tech_.setCurrentTime = (ct) => { currentTime = ct; }; this.player.src({ src: 'liveStart30sBefore.m3u8', type: 'application/vnd.apple.mpegurl' }); this.player.tech_.readyState = () => 4; this.clock.tick(1); this.player.tech_.triggerReady(); this.clock.tick(1); this.standardXHRResponse(this.requests.shift()); openMediaSource(this.player, this.clock); this.player.tech_.trigger('play'); this.clock.tick(1); assert.notEqual(currentTime, 0, 'seeked on autoplay'); }); QUnit.test('seeks to the start offset point', function(assert) { let currentTime = 0; this.player.autoplay(true); this.player.on('seeking', () => { currentTime = this.player.currentTime(); }); this.player.src({ src: 'startVod.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.trigger('play'); this.standardXHRResponse(this.requests.shift()); this.clock.tick(1); assert.strictEqual(currentTime, 10.3, 'seeked to positive offset'); }); QUnit.test('seeks to non-negative offet for a live stream', function(assert) { let currentTime = 0; this.player.autoplay(true); this.player.on('seeking', () => { currentTime = this.player.currentTime(); }); this.player.src({ src: 'startLive.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.trigger('play'); this.standardXHRResponse(this.requests.shift()); this.clock.tick(1); assert.strictEqual(currentTime, 0, 'seeked to offset on live stream'); }); QUnit.test('seeks to negative offset point', function(assert) { let currentTime = 0; this.player.autoplay(true); this.player.on('seeking', () => { currentTime = this.player.currentTime(); }); this.player.src({ src: 'startNegative.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.trigger('play'); this.standardXHRResponse(this.requests.shift()); this.clock.tick(1); assert.strictEqual(currentTime, 35, 'seeked to negative offset'); }); QUnit.test( 'duration is set when the source opens after the playlist is loaded', function(assert) { this.player.src({ src: 'media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.triggerReady(); this.clock.tick(1); this.standardXHRResponse(this.requests.shift()); openMediaSource(this.player, this.clock); assert.equal( this.player.tech_.vhs.mediaSource.duration, 40, 'set the duration' ); } ); QUnit.test('codecs are passed to the source buffer', function(assert) { const done = assert.async(); const codecs = []; this.player.src({ src: 'custom-codecs.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const addSourceBuffer = this.player.tech_.vhs.mediaSource.addSourceBuffer; this.player.tech_.vhs.mediaSource.addSourceBuffer = function(codec) { codecs.push(codec); return addSourceBuffer.call(this, codec); }; // main this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.dd00dd, mp4a.40.9"\n' + 'media.m3u8\n' ); // media this.standardXHRResponse(this.requests.shift()); // source buffer won't be created until we have our first segment this.player.tech(true).vhs.playlistController_.mainSegmentLoader_.one('appending', () => { // always create separate audio and video source buffers assert.equal(codecs.length, 2, 'created two source buffers'); assert.notEqual( codecs.indexOf('audio/mp4;codecs="mp4a.40.9"'), -1, 'specified the audio codec' ); assert.notEqual( codecs.indexOf('video/mp4;codecs="avc1.dd00dd"'), -1, 'specified the video codec' ); done(); }); // segment 0 this.standardXHRResponse(this.requests.shift(), muxedSegment()); }); QUnit.test('including HLS as a tech does not error', function(assert) { this.player.dispose(); this.player = createPlayer({ techOrder: ['vhs', 'html5'] }); this.clock.tick(1); assert.ok(this.player, 'created the player'); assert.equal(this.env.log.warn.calls, 2, 'logged two warnings for deprecations'); }); QUnit.test('creates a PlaylistLoader on init', function(assert) { this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); assert.equal(this.requests[0].aborted, true, 'aborted previous src'); this.standardXHRResponse(this.requests[1]); assert.ok( this.player.tech_.vhs.playlists.main, 'set the main playlist' ); assert.ok( this.player.tech_.vhs.playlists.media(), 'set the media playlist' ); assert.ok( this.player.tech_.vhs.playlists.media().segments, 'the segment entries are parsed' ); assert.strictEqual( this.player.tech_.vhs.playlists.main.playlists[0], this.player.tech_.vhs.playlists.media(), 'the playlist is selected' ); }); QUnit.test('sets the duration if one is available on the playlist', function(assert) { let events = 0; this.player.src({ src: 'manifest/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.on('durationchange', function() { events++; }); this.standardXHRResponse(this.requests[0]); assert.equal( this.player.tech_.vhs.mediaSource.duration, 40, 'set the duration' ); assert.equal(events, 1, 'durationchange is fired'); }); QUnit.test('estimates individual segment durations if needed', function(assert) { let changes = 0; this.player.src({ src: 'http://example.com/manifest/missingExtinf.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.mediaSource.duration = NaN; this.player.tech_.on('durationchange', function() { changes++; }); this.standardXHRResponse(this.requests[0]); assert.strictEqual( this.player.tech_.vhs.mediaSource.duration, this.player.tech_.vhs.playlists.media().segments.length * 10, 'duration is updated' ); assert.strictEqual(changes, 1, 'one durationchange fired'); }); QUnit.test( 'translates seekable by the starting time for live playlists', function(assert) { this.player.src({ src: 'media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-MEDIA-SEQUENCE:15\n' + '#EXT-X-TARGETDURATION:10\n' + '#EXTINF:10,\n' + '0.ts\n' + '#EXTINF:10,\n' + '1.ts\n' + '#EXTINF:10,\n' + '2.ts\n' + '#EXTINF:10,\n' + '3.ts\n' ); const seekable = this.player.seekable(); assert.equal(seekable.length, 1, 'one seekable range'); assert.equal(seekable.start(0), 0, 'the earliest possible position is at zero'); assert.equal(seekable.end(0), 10, 'end is relative to the start'); } ); QUnit.test('starts downloading a segment on loadedmetadata', function(assert) { const done = assert.async(); this.player.src({ src: 'manifest/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.buffered = function() { return createTimeRanges(0, 0); }; openMediaSource(this.player, this.clock); const segment = muxedSegment(); // copy the byte length since the segment bytes get cleared out const segmentByteLength = segment.byteLength; assert.ok(segmentByteLength, 'the segment has some number of bytes'); // media this.standardXHRResponse(this.requests[0]); assert.strictEqual( this.requests[1].url, absoluteUrl('manifest/media-00001.ts'), 'the first segment is requested' ); this.player.tech(true).vhs.playlistController_.mainSegmentLoader_.one('appending', () => { // verify stats assert.equal( this.player.tech_.vhs.stats.mediaBytesTransferred, segmentByteLength, 'transferred the segment byte length' ); assert.equal(this.player.tech_.vhs.stats.mediaRequests, 1, '1 request'); done(); }); // segment 0 this.standardXHRResponse(this.requests[1], segment); }); QUnit.test('re-initializes the handler for each source', function(assert) { let secondPlaylists; let secondMSE; this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const firstPlaylists = this.player.tech_.vhs.playlists; const firstMSE = this.player.tech_.vhs.mediaSource; // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); const pc = this.player.tech_.vhs.playlistController_; // need a segment request to complete for the source buffers to be created return requestAndAppendSegment({ request: this.requests.shift(), mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock, tickClock: false }).then(() => { let audioBufferAborts = 0; let videoBufferAborts = 0; pc.mainSegmentLoader_.sourceUpdater_.audioBuffer.abort = () => audioBufferAborts++; pc.mainSegmentLoader_.sourceUpdater_.videoBuffer.abort = () => videoBufferAborts++; // allow timeout for making another request this.clock.tick(1); assert.equal(this.requests.length, 1, 'made another request'); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); secondPlaylists = this.player.tech_.vhs.playlists; secondMSE = this.player.tech_.vhs.mediaSource; assert.equal(audioBufferAborts, 1, 'aborted the old audio source buffer'); assert.equal(videoBufferAborts, 1, 'aborted the old video source buffer'); assert.ok(this.requests[0].aborted, 'aborted the old segment request'); assert.notStrictEqual( firstPlaylists, secondPlaylists, 'the playlist object is not reused' ); assert.notStrictEqual(firstMSE, secondMSE, 'the media source object is not reused'); }); }); QUnit.test( 'triggers a media source error when an initial playlist request errors', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.pop().respond(500); assert.equal( this.player.tech_.vhs.mediaSource.error_, 'network', 'a network error is triggered' ); } ); QUnit.test( 'triggers a player error when an initial playlist request errors and the media source ' + 'isn\'t open', function(assert) { const done = assert.async(); const origError = videojs.log.error; const errLogs = []; const endOfStreams = []; videojs.log.error = (log) => errLogs.push(log); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); this.player.tech_.vhs.playlistController_.mediaSource.readyState = 'closed'; this.player.on('error', () => { const error = this.player.error(); assert.equal(error.code, 2, 'error has correct code'); assert.equal( error.message, 'HLS playlist request error at URL: manifest/main.m3u8.', 'error has correct message' ); assert.equal(errLogs.length, 1, 'logged an error'); videojs.log.error = origError; assert.notOk(this.player.tech_.vhs.mediaSource.error_, 'no media source error'); done(); }); this.requests.pop().respond(500); } ); QUnit.test('downloads media playlists after loading the main', function(assert) { const done = assert.async(); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 20e10; // main this.standardXHRResponse(this.requests[0]); // media this.standardXHRResponse(this.requests[1]); const segment = muxedSegment(); // copy the byte length since the segment bytes get cleared out const segmentByteLength = segment.byteLength; assert.ok(segmentByteLength, 'the segment has some number of bytes'); this.player.tech(true).vhs.playlistController_.mainSegmentLoader_.one('appending', () => { // verify stats assert.equal( this.player.tech_.vhs.stats.mediaBytesTransferred, segmentByteLength, 'transferred the segment byte length' ); assert.equal(this.player.tech_.vhs.stats.mediaRequests, 1, '1 request'); done(); }); // segment 0 this.standardXHRResponse(this.requests[2], segment); assert.strictEqual( this.requests[0].url, 'manifest/main.m3u8', 'main playlist requested' ); assert.strictEqual( this.requests[1].url, absoluteUrl('manifest/media2.m3u8'), 'media playlist requested' ); assert.strictEqual( this.requests[2].url, absoluteUrl('manifest/media2-00001.ts'), 'first segment requested' ); }); QUnit.test('setting bandwidth resets throughput', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.vhs.throughput = 1000; assert.strictEqual( this.player.tech_.vhs.throughput, 1000, 'throughput is set' ); this.player.tech_.vhs.bandwidth = 20e10; assert.strictEqual( this.player.tech_.vhs.throughput, 0, 'throughput is reset when bandwidth is specified' ); }); QUnit.test('a thoughput of zero is ignored in systemBandwidth', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.vhs.bandwidth = 20e10; assert.strictEqual( this.player.tech_.vhs.throughput, 0, 'throughput is reset when bandwidth is specified' ); assert.strictEqual( this.player.tech_.vhs.systemBandwidth, 20e10, 'systemBandwidth is the same as bandwidth' ); }); QUnit.test( 'systemBandwidth is a combination of thoughput and bandwidth', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.vhs.bandwidth = 20e10; this.player.tech_.vhs.throughput = 20e10; // 1 / ( 1 / 20e10 + 1 / 20e10) = 10e10 assert.strictEqual( this.player.tech_.vhs.systemBandwidth, 10e10, 'systemBandwidth is the combination of bandwidth and throughput' ); } ); QUnit.module('NetworkInformationApi', hooks => { hooks.beforeEach(function(assert) { this.env = useFakeEnvironment(assert); this.ogNavigator = window.navigator; this.clock = this.env.clock; this.resetNavigatorConnection = (connection = {}) => { // Need to delete the property before setting since navigator doesn't have a setter delete window.navigator; window.navigator = { connection }; }; }); hooks.afterEach(function() { this.env.restore(); window.navigator = this.ogNavigator; }); QUnit.test( 'bandwidth returns networkInformation.downlink when useNetworkInformationApi option is enabled', function(assert) { this.resetNavigatorConnection({ downlink: 10 }); this.player = createPlayer({ html5: { vhs: { useNetworkInformationApi: true } } }); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); // downlink in bits = 10 * 1000000 = 10e6 assert.strictEqual( this.player.tech_.vhs.bandwidth, 10e6, 'bandwidth equals networkInfo.downlink represented as bits per second' ); } ); QUnit.test( 'bandwidth uses player-estimated bandwidth when its value is greater than networkInformation.downLink and both values are >= 10 Mbps', function(assert) { this.resetNavigatorConnection({ // 10 Mbps or 10e6 downlink: 10 }); this.player = createPlayer({ html5: { vhs: { useNetworkInformationApi: true } } }); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.vhs.bandwidth = 20e6; assert.strictEqual( this.player.tech_.vhs.bandwidth, 20e6, 'bandwidth getter returned the player-estimated bandwidth value' ); } ); QUnit.test( 'bandwidth uses network-information-api bandwidth when its value is less than the player bandwidth and 10 Mbps', function(assert) { this.resetNavigatorConnection({ // 9 Mbps or 9e6 downlink: 9 }); this.player = createPlayer({ html5: { vhs: { useNetworkInformationApi: true } } }); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.vhs.bandwidth = 20e10; assert.strictEqual( this.player.tech_.vhs.bandwidth, 9e6, 'bandwidth getter returned the network-information-api bandwidth value since it was less than 10 Mbps' ); } ); QUnit.test( 'bandwidth uses player-estimated bandwidth when networkInformation is not supported', function(assert) { // Nullify the `connection` property on Navigator this.resetNavigatorConnection(null); this.player = createPlayer({ html5: { vhs: { useNetworkInformationApi: true } } }); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.vhs.bandwidth = 20e10; assert.strictEqual( this.player.tech_.vhs.bandwidth, 20e10, 'bandwidth getter returned the player-estimated bandwidth value' ); } ); }); QUnit.test('requests a reasonable rendition to start', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse( this.requests[0], '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=50\n' + 'mediaLow.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=240000\n' + 'mediaNormal.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=19280000000\n' + 'mediaHigh.m3u8\n' ); assert.strictEqual( this.requests[0].url, 'manifest/main.m3u8', 'main playlist requested' ); assert.strictEqual( this.requests[1].url, absoluteUrl('manifest/mediaNormal.m3u8'), 'reasonable bandwidth media playlist requested' ); }); QUnit.test('upshifts if the initial bandwidth hint is high', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 10e20; this.standardXHRResponse( this.requests[0], '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=50\n' + 'mediaLow.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=240000\n' + 'mediaNormal.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=19280000000\n' + 'mediaHigh.m3u8\n' ); assert.strictEqual( this.requests[0].url, 'manifest/main.m3u8', 'main playlist requested' ); assert.strictEqual( this.requests[1].url, absoluteUrl('manifest/mediaHigh.m3u8'), 'high bandwidth media playlist requested' ); }); QUnit.test('downshifts if the initial bandwidth hint is low', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 100; this.standardXHRResponse( this.requests[0], '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=50\n' + 'mediaLow.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=240000\n' + 'mediaNormal.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=19280000000\n' + 'mediaHigh.m3u8\n' ); assert.strictEqual( this.requests[0].url, 'manifest/main.m3u8', 'main playlist requested' ); assert.strictEqual( this.requests[1].url, absoluteUrl('manifest/mediaLow.m3u8'), 'low bandwidth media playlist requested' ); }); QUnit.test('buffer checks are noops until a media playlist is ready', function(assert) { this.player.src({ src: 'manifest/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.clock.tick(10 * 1000); assert.strictEqual(1, this.requests.length, 'one request was made'); assert.strictEqual( this.requests[0].url, 'manifest/media.m3u8', 'media playlist requested' ); }); QUnit.test('buffer checks are noops when only the main is ready', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); // ignore any outstanding segment requests this.requests.length = 0; // load in a new playlist which will cause playlists.media() to be // undefined while it is being fetched this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); // respond with the main playlist but don't send the media playlist yet // force media1 to be requested this.player.tech_.vhs.bandwidth = 1; // main this.standardXHRResponse(this.requests.shift()); this.clock.tick(10 * 1000); assert.strictEqual(this.requests.length, 1, 'one request was made'); assert.strictEqual( this.requests[0].url, absoluteUrl('manifest/media1.m3u8'), 'media playlist requested' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 1, 'bandwidth set above'); }); QUnit.test('selects a playlist below the current bandwidth', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests[0]); // the default playlist has a really high bitrate this.player.tech_.vhs.playlists.main.playlists[0].attributes.BANDWIDTH = 9e10; // playlist 1 has a very low bitrate this.player.tech_.vhs.playlists.main.playlists[1].attributes.BANDWIDTH = 1; // but the detected client bandwidth is really low this.player.tech_.vhs.bandwidth = 10; const playlist = this.player.tech_.vhs.selectPlaylist(); assert.strictEqual( playlist, this.player.tech_.vhs.playlists.main.playlists[1], 'the low bitrate stream is selected' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 10, 'bandwidth set above'); }); QUnit.test( 'selects a primary rendition when there are multiple rendtions share same attributes', function(assert) { let playlist; this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); standardXHRResponse(this.requests[0]); // covers playlists with same bandwidth but different resolution and different bandwidth // but same resolution this.player.tech_.vhs.playlists.main.playlists[0].attributes.BANDWIDTH = 528; this.player.tech_.vhs.playlists.main.playlists[1].attributes.BANDWIDTH = 528; this.player.tech_.vhs.playlists.main.playlists[2].attributes.BANDWIDTH = 728; this.player.tech_.vhs.playlists.main.playlists[3].attributes.BANDWIDTH = 728; this.player.tech_.vhs.bandwidth = 1000; playlist = this.player.tech_.vhs.selectPlaylist(); assert.strictEqual( playlist, this.player.tech_.vhs.playlists.main.playlists[2], 'select the rendition with largest bandwidth and just-larger-than video player' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 1000, 'bandwidth set above'); // covers playlists share same bandwidth and resolutions this.player.tech_.vhs.playlists.main.playlists[0].attributes.BANDWIDTH = 728; this.player.tech_.vhs.playlists.main.playlists[0].attributes.RESOLUTION.width = 960; this.player.tech_.vhs.playlists.main.playlists[0].attributes.RESOLUTION.height = 540; this.player.tech_.vhs.playlists.main.playlists[1].attributes.BANDWIDTH = 728; this.player.tech_.vhs.playlists.main.playlists[2].attributes.BANDWIDTH = 728; this.player.tech_.vhs.playlists.main.playlists[2].attributes.RESOLUTION.width = 960; this.player.tech_.vhs.playlists.main.playlists[2].attributes.RESOLUTION.height = 540; this.player.tech_.vhs.playlists.main.playlists[3].attributes.BANDWIDTH = 728; this.player.tech_.vhs.bandwidth = 1000; playlist = this.player.tech_.vhs.selectPlaylist(); assert.strictEqual( playlist, this.player.tech_.vhs.playlists.main.playlists[0], 'the primary rendition is selected' ); } ); QUnit.test('allows initial bandwidth to be provided', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 500; this.requests[0].bandwidth = 1; this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-PLAYLIST-TYPE:VOD\n' + '#EXT-X-TARGETDURATION:10\n' ); assert.equal( this.player.tech_.vhs.bandwidth, 500, 'prefers user-specified initial bandwidth' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 500, 'bandwidth set above'); }); QUnit.test('raises the minimum bitrate for a stream proportionially', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests[0]); // the default playlist's bandwidth + 10% is assert.equal to the current bandwidth this.player.tech_.vhs.playlists.main.playlists[0].attributes.BANDWIDTH = 10; this.player.tech_.vhs.bandwidth = 11; // 9.9 * 1.1 < 11 this.player.tech_.vhs.playlists.main.playlists[1].attributes.BANDWIDTH = 9.9; const playlist = this.player.tech_.vhs.selectPlaylist(); assert.strictEqual( playlist, this.player.tech_.vhs.playlists.main.playlists[1], 'a lower bitrate stream is selected' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 11, 'bandwidth set above'); }); QUnit.test('uses the lowest bitrate if no other is suitable', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests[0]); // the lowest bitrate playlist is much greater than 1b/s this.player.tech_.vhs.bandwidth = 1; const playlist = this.player.tech_.vhs.selectPlaylist(); // playlist 1 has the lowest advertised bitrate assert.strictEqual( playlist, this.player.tech_.vhs.playlists.main.playlists[1], 'the lowest bitrate stream is selected' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 1, 'bandwidth set above'); }); QUnit.test('selects the correct rendition by tech dimensions', function(assert) { let playlist; this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests[0]); const vhs = this.player.tech_.vhs; this.player.width(640); this.player.height(360); vhs.bandwidth = 3000000; playlist = vhs.selectPlaylist(); assert.deepEqual( playlist.attributes.RESOLUTION, {width: 960, height: 540}, 'should return the correct resolution by tech dimensions' ); assert.equal( playlist.attributes.BANDWIDTH, 1928000, 'should have the expected bandwidth in case of multiple' ); this.player.width(1920); this.player.height(1080); vhs.bandwidth = 3000000; playlist = vhs.selectPlaylist(); assert.deepEqual( playlist.attributes.RESOLUTION, {width: 960, height: 540}, 'should return the correct resolution by tech dimensions' ); assert.equal( playlist.attributes.BANDWIDTH, 1928000, 'should have the expected bandwidth in case of multiple' ); this.player.width(396); this.player.height(224); playlist = vhs.selectPlaylist(); assert.deepEqual( playlist.attributes.RESOLUTION, {width: 396, height: 224}, 'should return the correct resolution by ' + 'tech dimensions, if exact match' ); assert.equal( playlist.attributes.BANDWIDTH, 440000, 'should have the expected bandwidth in case of multiple, if exact match' ); this.player.width(395); this.player.height(222); playlist = this.player.tech_.vhs.selectPlaylist(); assert.deepEqual( playlist.attributes.RESOLUTION, {width: 396, height: 224}, 'should return the next larger resolution by tech dimensions, ' + 'if no exact match exists' ); assert.equal( playlist.attributes.BANDWIDTH, 440000, 'should have the expected bandwidth in case of multiple, if exact match' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 3000000, 'bandwidth set above'); }); QUnit.test('selects the highest bitrate playlist when the player dimensions are ' + 'larger than any of the variants', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // main this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1000,RESOLUTION=2x1\n' + 'media.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1,RESOLUTION=1x1\n' + 'media1.m3u8\n' ); // media this.standardXHRResponse(this.requests.shift()); this.player.tech_.vhs.bandwidth = 1e10; this.player.width(1024); this.player.height(768); const playlist = this.player.tech_.vhs.selectPlaylist(); assert.equal( playlist.attributes.BANDWIDTH, 1000, 'selected the highest bandwidth variant' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 1e10, 'bandwidth set above'); }); QUnit.test('filters playlists that are currently excluded', function(assert) { let playlist; this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 1e10; // main this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1000\n' + 'media.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + 'media1.m3u8\n' ); // media this.standardXHRResponse(this.requests.shift()); // exclude the current playlist this.player.tech_.vhs.playlists.main.playlists[0].excludeUntil = +new Date() + 1000; playlist = this.player.tech_.vhs.selectPlaylist(); assert.equal( playlist, this.player.tech_.vhs.playlists.main.playlists[1], 'respected exclusions' ); // timeout the exclusion this.clock.tick(1000); playlist = this.player.tech_.vhs.selectPlaylist(); assert.equal( playlist, this.player.tech_.vhs.playlists.main.playlists[0], 'expired the exclusion' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 1e10, 'bandwidth set above'); }); QUnit.test('does not exclude compatible H.264 codec strings', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 1; // main this.requests.shift() .respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.5"\n' + 'media.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.4d400f,mp4a.40.5"\n' + 'media1.m3u8\n' ); // media this.standardXHRResponse(this.requests.shift()); const main = this.player.tech_.vhs.playlists.main; const loader = this.player.tech_.vhs.playlistController_.mainSegmentLoader_; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true}; loader.trigger('trackinfo'); assert.strictEqual( typeof main.playlists[0].excludeUntil, 'undefined', 'did not exclude' ); assert.strictEqual( typeof main.playlists[1].excludeUntil, 'undefined', 'did not exclude' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 1, 'bandwidth set above'); }); QUnit.test('does not exclude compatible AAC codec strings', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // main this.requests.shift() .respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.2"\n' + 'media.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.4d400d,not-an-audio-codec"\n' + 'media1.m3u8\n' ); // media this.standardXHRResponse(this.requests.shift()); const loader = this.player.tech_.vhs.playlistController_.mainSegmentLoader_; const main = this.player.tech_.vhs.playlists.main; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true}; loader.trigger('trackinfo'); assert.strictEqual( typeof main.playlists[0].excludeUntil, 'undefined', 'did not exclude mp4a.40.2' ); assert.strictEqual( main.playlists[1].excludeUntil, Infinity, 'excluded invalid audio codec' ); }); QUnit.test('excludes incompatible playlists by codec, without codec switching', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const playlistString = '#EXTM3U\n' + // selected playlist '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.2"\n' + 'media.m3u8\n' + // compatible with selected playlist '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.2"\n' + 'media1.m3u8\n' + // incompatible by audio codec difference '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,ac-3"\n' + 'media2.m3u8\n' + // incompatible by video codec difference '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="hvc1.4d400d,mp4a.40.2"\n' + 'media3.m3u8\n' + // incompatible, only audio codec '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="mp4a.40.2"\n' + 'media4.m3u8\n' + // incompatible, only video codec '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d"\n' + 'media5.m3u8\n' + // compatible with selected playlist '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1,mp4a"\n' + 'media6.m3u8\n'; // main this.requests.shift().respond(200, null, playlistString); // media this.standardXHRResponse(this.requests.shift()); const pc = this.player.tech_.vhs.playlistController_; const loader = pc.mainSegmentLoader_; const main = this.player.tech_.vhs.playlists.main; pc.sourceUpdater_.canChangeType = () => false; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true}; loader.trigger('trackinfo'); const playlists = main.playlists; assert.strictEqual(playlists.length, 7, 'six playlists total'); assert.strictEqual(typeof playlists[0].excludeUntil, 'undefined', 'did not exclude first playlist'); assert.strictEqual(typeof playlists[1].excludeUntil, 'undefined', 'did not exclude second playlist'); assert.strictEqual(playlists[2].excludeUntil, Infinity, 'excluded incompatible audio playlist'); assert.strictEqual(playlists[3].excludeUntil, Infinity, 'excluded incompatible video playlist'); assert.strictEqual(playlists[4].excludeUntil, Infinity, 'excluded audio only playlist'); assert.strictEqual(playlists[5].excludeUntil, Infinity, 'excluded video only playlist'); assert.strictEqual(typeof playlists[6].excludeUntil, 'undefined', 'did not exclude seventh playlist'); }); QUnit.test('does not exclude incompatible codecs with codec switching', function(assert) { const oldIsTypeSupported = window.MediaSource.isTypeSupported; window.MediaSource.isTypeSupported = (t) => (/avc1|mp4a/).test(t); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const playlistString = '#EXTM3U\n' + // selected playlist '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.2"\n' + 'media.m3u8\n' + // compatible with selected playlist '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.2"\n' + 'media1.m3u8\n' + // incompatible by audio codec difference '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,ac-3"\n' + 'media2.m3u8\n' + // incompatible by video codec difference '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="hvc1.4d400d,mp4a.40.2"\n' + 'media3.m3u8\n' + // incompatible, only audio codec '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="mp4a.40.2"\n' + 'media4.m3u8\n' + // incompatible, only video codec '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d"\n' + 'media5.m3u8\n' + // compatible with selected playlist '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1,mp4a"\n' + 'media6.m3u8\n'; // main this.requests.shift().respond(200, null, playlistString); // media this.standardXHRResponse(this.requests.shift()); const pc = this.player.tech_.vhs.playlistController_; const loader = pc.mainSegmentLoader_; const main = this.player.tech_.vhs.playlists.main; pc.sourceUpdater_.canChangeType = () => true; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true}; loader.trigger('trackinfo'); const playlists = main.playlists; assert.strictEqual(playlists.length, 7, 'six playlists total'); assert.strictEqual(typeof playlists[0].excludeUntil, 'undefined', 'did not exclude first playlist'); assert.strictEqual(typeof playlists[1].excludeUntil, 'undefined', 'did not exclude second playlist'); assert.strictEqual(playlists[2].excludeUntil, Infinity, 'exclude incompatible audio playlist'); assert.strictEqual(playlists[3].excludeUntil, Infinity, 'exclude incompatible video playlist'); assert.strictEqual(playlists[4].excludeUntil, Infinity, 'exclude audio only playlist'); assert.strictEqual(playlists[5].excludeUntil, Infinity, 'exclude video only playlist'); assert.strictEqual(typeof playlists[6].excludeUntil, 'undefined', 'did not exclude seventh playlist'); window.MediaSource.isTypeSupported = oldIsTypeSupported; }); QUnit.test('excludes fmp4 playlists by browser support', function(assert) { const oldIsTypeSupported = window.MediaSource.isTypeSupported; window.MediaSource.isTypeSupported = (t) => (/avc1|mp4a/).test(t); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const playlistString = '#EXTM3U\n' + // video not supported '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="hvc1,mp4a.40.2"\n' + 'media.m3u8\n' + // audio not supported '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,ac-3"\n' + 'media.m3u8\n' + // supported! '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.2"\n' + 'media1.m3u8\n'; const pc = this.player.tech_.vhs.playlistController_; // do not exclude incompatible so that we can run this test. pc.excludeUnsupportedVariants_ = () => {}; // main this.requests.shift().respond(200, null, playlistString); // media this.standardXHRResponse(this.requests.shift()); const playlistLoader = pc.mainPlaylistLoader_; const loader = pc.mainSegmentLoader_; const main = this.player.tech_.vhs.playlists.main; let debugLogs = []; pc.logger_ = (...logs) => { debugLogs = debugLogs.concat(logs); }; const playlists = main.playlists; playlistLoader.media = () => playlists[0]; loader.mainStartingMedia_ = playlists[0]; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true, isFmp4: true}; loader.trigger('trackinfo'); playlistLoader.media = () => playlists[1]; loader.mainStartingMedia_ = playlists[1]; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true, isFmp4: true}; loader.trigger('trackinfo'); playlistLoader.media = () => playlists[2]; loader.mainStartingMedia_ = playlists[2]; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true, isFmp4: true}; loader.trigger('trackinfo'); assert.strictEqual(playlists.length, 3, 'three playlists total'); assert.strictEqual(playlists[0].excludeUntil, Infinity, 'excluded first playlist'); assert.strictEqual(playlists[1].excludeUntil, Infinity, 'excluded second playlist'); assert.strictEqual(typeof playlists[2].excludeUntil, 'undefined', 'did not exclude second playlist'); assert.deepEqual(debugLogs, [ `Internal problem encountered with playlist ${playlists[0].id}. browser does not support codec(s): "hvc1". Switching to playlist ${playlists[1].id}.`, `switch media ${playlists[0].id} -> ${playlists[1].id} from exclude`, `Internal problem encountered with playlist ${playlists[1].id}. browser does not support codec(s): "ac-3". Switching to playlist ${playlists[2].id}.`, `switch media ${playlists[1].id} -> ${playlists[2].id} from exclude` ], 'debug log as expected'); window.MediaSource.isTypeSupported = oldIsTypeSupported; }); QUnit.test('excludes ts playlists by muxer support', function(assert) { const oldIsTypeSupported = window.MediaSource.isTypeSupported; window.MediaSource.isTypeSupported = (t) => true; this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const playlistString = '#EXTM3U\n' + // video not supported '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="hvc1,mp4a.40.2"\n' + 'media.m3u8\n' + // audio not supported '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,ac-3"\n' + 'media.m3u8\n' + // supported! '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.2"\n' + 'media1.m3u8\n'; // main this.requests.shift().respond(200, null, playlistString); // media this.standardXHRResponse(this.requests.shift()); const pc = this.player.tech_.vhs.playlistController_; const playlistLoader = pc.mainPlaylistLoader_; const loader = pc.mainSegmentLoader_; const main = this.player.tech_.vhs.playlists.main; let debugLogs = []; pc.logger_ = (...logs) => { debugLogs = debugLogs.concat(logs); }; const playlists = main.playlists; playlistLoader.media = () => playlists[0]; loader.mainStartingMedia_ = playlists[0]; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true}; loader.trigger('trackinfo'); playlistLoader.media = () => playlists[1]; loader.mainStartingMedia_ = playlists[1]; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true}; loader.trigger('trackinfo'); playlistLoader.media = () => playlists[2]; loader.mainStartingMedia_ = playlists[2]; loader.currentMediaInfo_ = {hasVideo: true, hasAudio: true}; loader.trigger('trackinfo'); assert.strictEqual(playlists.length, 3, 'three playlists total'); assert.strictEqual(playlists[0].excludeUntil, Infinity, 'excluded first playlist'); assert.strictEqual(playlists[1].excludeUntil, Infinity, 'excluded second playlist'); assert.strictEqual(typeof playlists[2].excludeUntil, 'undefined', 'did not exclude third playlist'); assert.deepEqual(debugLogs, [ `Internal problem encountered with playlist ${playlists[0].id}. muxer does not support codec(s): "hvc1". Switching to playlist ${playlists[1].id}.`, `switch media ${playlists[0].id} -> ${playlists[1].id} from exclude`, `Internal problem encountered with playlist ${playlists[1].id}. muxer does not support codec(s): "ac-3". Switching to playlist ${playlists[2].id}.`, `switch media ${playlists[1].id} -> ${playlists[2].id} from exclude` ], 'debug log as expected'); window.MediaSource.isTypeSupported = oldIsTypeSupported; }); QUnit.test('cancels outstanding XHRs when seeking', function(assert) { this.player.src({ src: 'manifest/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests[0]); this.player.tech_.vhs.media = { segments: [{ uri: '0.ts', duration: 10 }, { uri: '1.ts', duration: 10 }] }; // attempt to seek while the download is in progress this.player.currentTime(7); this.clock.tick(2); assert.ok(this.requests[1].aborted, 'XHR aborted'); assert.strictEqual(this.requests.length, 3, 'opened new XHR'); }); QUnit.test('does not abort segment loading for in-buffer seeking', function(assert) { this.player.src({ src: 'manifest/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests.shift()); this.player.tech_.buffered = function() { return createTimeRanges(0, 20); }; this.player.tech_.setCurrentTime(11); this.clock.tick(1); assert.equal(this.requests.length, 1, 'did not abort the outstanding request'); }); QUnit.test('unsupported playlist should not be re-included when excluding last playlist', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 1; // main this.requests.shift() .respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.2"\n' + 'media.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.4d400d,not-an-audio-codec"\n' + 'media1.m3u8\n' ); // media this.standardXHRResponse(this.requests.shift()); const main = this.player.tech_.vhs.playlists.main; const media = this.player.tech_.vhs.playlists.media_; const pc = this.player.tech_.vhs.playlistController_; return requestAndAppendSegment({ request: this.requests.shift(), mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock }).then(() => { assert.strictEqual( main.playlists[1].excludeUntil, Infinity, 'excluded invalid audio codec' ); const requri = this.requests[0].uri; this.requests.shift().respond(400); assert.ok(main.playlists[0].excludeUntil > 0, 'original media excluded for some time'); assert.strictEqual( main.playlists[1].excludeUntil, Infinity, 'audio codec still excluded' ); assert.equal(this.env.log.warn.calls, 1, 'warning logged for exclusion'); assert.equal( this.env.log.warn.args[0][0], `Problem encountered with playlist ${main.playlists[0].id}. HLS request errored at URL: ${requri} Switching to playlist 0-media.m3u8.`, 'log generic error message' ); }); }); QUnit.test('segment 404 should trigger exclusion of media', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 20000; // main this.standardXHRResponse(this.requests[0]); // media this.standardXHRResponse(this.requests[1]); const media = this.player.tech_.vhs.playlists.media_; // segment this.requests[2].respond(400); assert.ok(media.excludeUntil > 0, 'original media excluded for some time'); assert.equal(this.env.log.warn.calls, 1, 'warning logged for exclusion'); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 20000, 'bandwidth set above'); }); QUnit.test('playlist 404 should exclude media', function(assert) { let media; let url; let index; let excludedplaylist = 0; let retryplaylist = 0; let vhsRenditionExcludedEvents = 0; this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.on('excludeplaylist', () => excludedplaylist++); this.player.tech_.on('retryplaylist', () => retryplaylist++); this.player.tech_.on('usage', (event) => { if (event.name === 'vhs-rendition-excluded') { vhsRenditionExcludedEvents++; } }); this.player.tech_.vhs.bandwidth = 1e10; // main this.requests[0].respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1000\n' + 'media.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + 'media1.m3u8\n' ); assert.equal( typeof this.player.tech_.vhs.playlists.media_, 'undefined', 'no media is initially set' ); assert.equal(excludedplaylist, 0, 'there is no excluded playlist'); assert.equal( vhsRenditionExcludedEvents, 0, 'no vhs-rendition-excluded event was fired' ); // media this.requests[1].respond(404); url = this.requests[1].url.slice(this.requests[1].url.lastIndexOf('/') + 1); if (url === 'media.m3u8') { index = 0; } else { index = 1; } media = this.player.tech_.vhs.playlists.main.playlists[createPlaylistID(index, url)]; assert.ok(media.excludeUntil > 0, 'original media excluded for some time'); assert.equal(this.env.log.warn.calls, 1, 'warning logged for exclusion'); assert.equal( this.env.log.warn.args[0], `Problem encountered with playlist ${media.id}. HLS playlist request error at URL: media.m3u8. Switching to playlist 1-media1.m3u8.`, 'log generic error message' ); assert.equal(excludedplaylist, 1, 'there is one excluded playlist'); assert.equal( vhsRenditionExcludedEvents, 1, 'a vhs-rendition-excluded event was fired' ); assert.equal(retryplaylist, 0, 'haven\'t retried any playlist'); // request for the final available media this.requests[2].respond(404); url = this.requests[2].url.slice(this.requests[2].url.lastIndexOf('/') + 1); if (url === 'media.m3u8') { index = 0; } else { index = 1; } media = this.player.tech_.vhs.playlists.main.playlists[createPlaylistID(index, url)]; assert.ok(media.excludeUntil > 0, 'second media was excluded after playlist 404'); assert.equal(this.env.log.warn.calls, 2, 'warning logged for exclusion'); assert.equal( this.env.log.warn.args[1], 'Removing other playlists from the exclusion list because the last rendition is about to be excluded.', 'log generic error message' ); assert.equal( this.env.log.warn.args[2], `Problem encountered with playlist ${media.id}. HLS playlist request error at URL: media1.m3u8. ` + 'Switching to playlist 0-media.m3u8.', 'log generic error message' ); assert.equal(retryplaylist, 1, 'fired a retryplaylist event'); assert.equal(excludedplaylist, 2, 'media1 is excluded'); this.clock.tick(2 * 1000); // no new request was made since it hasn't been half the segment duration assert.strictEqual(3, this.requests.length, 'no new request was made'); this.clock.tick(3 * 1000); // loading the first playlist since the exclusion duration was cleared // when half the segment duaration passed assert.strictEqual(4, this.requests.length, 'one more request was made'); url = this.requests[3].url.slice(this.requests[3].url.lastIndexOf('/') + 1); if (url === 'media.m3u8') { index = 0; } else { index = 1; } media = this.player.tech_.vhs.playlists.main.playlists[createPlaylistID(index, url)]; // the first media was removed from exclusion after a refresh delay assert.ok(!media.excludeUntil, 'removed first media from exclusion'); assert.strictEqual( this.requests[3].url, absoluteUrl('manifest/media.m3u8'), 'media playlist requested' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 1e10, 'bandwidth set above'); }); QUnit.test('excludes playlist if it has stopped being updated', function(assert) { let playliststuck = 0; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); this.player.tech_.triggerReady(); this.standardXHRResponse(this.requests.shift()); this.player.tech_.vhs.playlistController_.seekable = function() { return createTimeRanges(90, 130); }; this.player.tech_.setCurrentTime(170); this.player.tech_.buffered = function() { return createTimeRanges(0, 170); }; Vhs.Playlist.playlistEnd = function() { return 170; }; this.player.tech_.on('playliststuck', () => playliststuck++); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-MEDIA-SEQUENCE:16\n' + '#EXTINF:10,\n' + '16.ts\n' ); assert.ok( !this.player.tech_.vhs.playlists.media().excludeUntil, 'playlist was not excluded' ); assert.equal(this.env.log.warn.calls, 0, 'no warning logged for exclusion'); assert.equal(playliststuck, 0, 'there is no stuck playlist'); this.player.tech_.trigger('play'); this.player.tech_.trigger('playing'); // trigger a refresh this.clock.tick(10 * 1000); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-MEDIA-SEQUENCE:16\n' + '#EXTINF:10,\n' + '16.ts\n' ); const media = this.player.tech_.vhs.playlists.media(); assert.ok( media.excludeUntil > 0, 'playlist excluded for some time' ); assert.equal(this.env.log.warn.calls, 1, 'warning logged for exclusion'); assert.equal( this.env.log.warn.args[0], `Problem encountered with playlist ${media.id}. ` + 'Playlist no longer updating. Switching to playlist 0-media.m3u8.', 'log specific error message for not updated playlist' ); assert.equal(playliststuck, 1, 'there is one stuck playlist'); }); QUnit.test('never excluded the playlist if it is the only playlist', function(assert) { this.player.src({ src: 'manifest/media.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXTINF:10,\n' + '0.ts\n' ); this.clock.tick(10 * 1000); this.requests.shift().respond(404); const media = this.player.tech_.vhs.playlists.media(); // media wasn't excluded because it's the only rendition assert.ok(!media.excludeUntil, 'media was not excluded after playlist 404'); assert.equal(this.env.log.warn.calls, 1, 'warning logged for exclusion'); assert.equal( this.env.log.warn.args[0], `Problem encountered with playlist ${media.id}. ` + 'Trying again since it is the only playlist.', 'log specific error message for the only playlist' ); }); QUnit.test( 'error on the first playlist request does not trigger an error when there is main ' + 'playlist with only one media playlist', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); this.requests[0] .respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1000\n' + 'media.m3u8\n' ); this.requests[1].respond(404); const url = this.requests[1].url.slice(this.requests[1].url.lastIndexOf('/') + 1); const media = this.player.tech_.vhs.playlists.main.playlists[createPlaylistID(0, url)]; // media wasn't excluded because it's the only rendition assert.ok(!media.excludeUntil, 'media was not excluded after playlist 404'); assert.equal(this.env.log.warn.calls, 1, 'warning logged for exclusion'); assert.equal( this.env.log.warn.args[0], `Problem encountered with playlist ${media.id}. ` + 'Trying again since it is the only playlist.', 'log specific error message for the onlyplaylist' ); } ); QUnit.test('seeking in an empty playlist is a non-erroring noop', function(assert) { this.player.src({ src: 'manifest/empty-live.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.shift().respond(200, null, '#EXTM3U\n'); const requestsLength = this.requests.length; this.player.tech_.setCurrentTime(183); this.clock.tick(1); assert.equal(this.requests.length, requestsLength, 'made no additional requests'); }); QUnit.test('fire loadedmetadata once we successfully load a playlist', function(assert) { let count = 0; this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const vhs = this.player.tech_.vhs; vhs.bandwidth = 20000; vhs.playlistController_.mainPlaylistLoader_.on('loadedmetadata', function() { count += 1; }); // main this.standardXHRResponse(this.requests.shift()); assert.equal( count, 0, 'loadedMedia not triggered before requesting playlist' ); // media this.requests.shift().respond(404); assert.equal( count, 0, 'loadedMedia not triggered after playlist 404' ); assert.equal(this.env.log.warn.calls, 1, 'warning logged for exclusion'); // media this.standardXHRResponse(this.requests.shift()); assert.equal( count, 1, 'loadedMedia triggered after successful recovery from 404' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 20000, 'bandwidth set above'); }); QUnit.test('sets seekable and duration for live playlists', function(assert) { this.player.src({ src: 'http://example.com/manifest/missingEndlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // since the safe live end will be 3 target durations back, in order for there to be a // positive seekable end, there should be at least 4 segments this.requests.shift().respond(200, null, ` #EXTM3U #EXT-X-TARGETDURATION:5 #EXTINF:5 0.ts #EXTINF:5 1.ts #EXTINF:5 2.ts #EXTINF:5 3.ts `); assert.equal(this.player.tech(true).vhs.seekable().length, 1, 'set one seekable range'); assert.equal(this.player.tech(true).vhs.seekable().start(0), 0, 'set seekable start'); assert.equal(this.player.tech(true).vhs.seekable().end(0), 5, 'set seekable end'); assert.strictEqual( this.player.tech(true).vhs.duration(), Infinity, 'duration reported by VHS is infinite' ); assert.strictEqual( this.player.tech(true).vhs.mediaSource.duration, this.player.tech(true).vhs.seekable().end(0), 'duration on the mediaSource is seekable end' ); }); QUnit.test('live playlist starts with correct currentTime value', function(assert) { this.player.src({ src: 'http://example.com/manifest/liveStart30sBefore.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests[0]); let currentTime = 0; this.player.tech_.setCurrentTime = (ct) => { currentTime = ct; }; this.player.tech_.readyState = () => 4; this.player.tech_.vhs.playlists.trigger('loadedmetadata'); this.player.tech_.paused = function() { return false; }; this.player.tech_.trigger('play'); this.clock.tick(1); const media = this.player.tech_.vhs.playlists.media(); assert.strictEqual( currentTime, Vhs.Playlist.seekable(media).end(0), 'currentTime is updated at playback' ); }); QUnit.test( 'estimates seekable ranges for live streams that have been paused for a long time', function(assert) { this.player.src({ src: 'http://example.com/manifest/liveStart30sBefore.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests.shift()); this.player.tech_.vhs.playlists.media().mediaSequence = 172; this.player.tech_.vhs.playlists.media().syncInfo = { mediaSequence: 130, time: 80 }; this.player.tech_.vhs.playlistController_.onSyncInfoUpdate_(); assert.equal( this.player.seekable().start(0), 500, 'offset the seekable start' ); } ); QUnit.test('resets the time to the live point when resuming a live stream after a ' + 'long break', function(assert) { let seekTarget; this.player.src({ src: 'live0.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-MEDIA-SEQUENCE:16\n' + '#EXTINF:10,\n' + '16.ts\n' ); // mock out the player to simulate a live stream that has been // playing for awhile this.player.tech_.vhs.seekable = function() { return createTimeRanges(160, 170); }; this.player.tech_.setCurrentTime = function(time) { if (typeof time !== 'undefined') { seekTarget = time; } }; this.player.tech_.played = function() { return createTimeRanges(120, 170); }; this.player.tech_.trigger('playing'); const seekable = this.player.seekable(); this.player.tech_.trigger('play'); assert.equal(seekTarget, seekable.end(seekable.length - 1), 'seeked to live point'); this.player.tech_.trigger('seeked'); }); QUnit.test( 'reloads out-of-date live playlists when switching variants', function(assert) { const oldManifest = testDataManifests['variant-update']; this.player.src({ src: 'http://example.com/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.main = { playlists: [{ mediaSequence: 15, segments: [1, 1, 1] }, { uri: 'http://example.com/variant-update.m3u8', mediaSequence: 0, segments: [1, 1] }] }; // playing segment 15 on playlist zero this.player.tech_.vhs.media = this.player.tech_.vhs.main.playlists[0]; this.player.mediaIndex = 1; testDataManifests['variant-update'] = '#EXTM3U\n' + '#EXT-X-MEDIA-SEQUENCE:16\n' + '#EXTINF:10,\n' + '16.ts\n' + '#EXTINF:10,\n' + '17.ts\n'; // switch playlists this.player.tech_.vhs.selectPlaylist = function() { return this.player.tech_.vhs.main.playlists[1]; }; // timeupdate downloads segment 16 then switches playlists this.player.trigger('timeupdate'); assert.strictEqual(this.player.mediaIndex, 1, 'mediaIndex points at the next segment'); testDataManifests['variant-update'] = oldManifest; } ); QUnit.test( 'if withCredentials global option is used, withCredentials is set on the XHR object', function(assert) { const vhsOptions = videojs.options.vhs; this.player.dispose(); videojs.options.vhs = { withCredentials: true }; this.player.dispose(); this.player = createPlayer(); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); assert.ok( this.requests[0].withCredentials, 'with credentials should be set to true if that option is passed in' ); videojs.options.vhs = vhsOptions; } ); QUnit.test('the withCredentials option overrides the global default', function(assert) { const vhsOptions = videojs.options.vhs; this.player.dispose(); videojs.options.vhs = { withCredentials: true }; this.player = createPlayer(); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl', withCredentials: false }); this.clock.tick(1); openMediaSource(this.player, this.clock); assert.ok( !this.requests[0].withCredentials, 'with credentials should be set to false if if overrode global option' ); videojs.options.vhs = vhsOptions; }); QUnit.test('playlist exclusion duration is set through options', function(assert) { const vhsOptions = videojs.options.vhs; this.player.dispose(); videojs.options.vhs = { playlistExclusionDuration: 3 * 60 }; this.player = createPlayer(); this.player.src({ src: 'http://example.com/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.player.tech_.triggerReady(); openMediaSource(this.player, this.clock); this.requests[0].respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1000\n' + 'media.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + 'media1.m3u8\n' ); this.requests[1].respond(404); // media const url = this.requests[1].url.slice(this.requests[1].url.lastIndexOf('/') + 1); let index; if (url === 'media.m3u8') { index = 0; } else { index = 1; } const media = this.player.tech_.vhs.playlists.main.playlists[createPlaylistID(index, url)]; assert.ok(media.excludeUntil > 0, 'original media excluded for some time'); assert.equal(this.env.log.warn.calls, 1, 'warning logged for exclusion'); assert.equal( this.env.log.warn.args[0], `Problem encountered with playlist ${media.id}. ` + 'HLS playlist request error at URL: media.m3u8. ' + 'Switching to playlist 1-media1.m3u8.', 'log generic error message' ); // this takes one millisecond this.standardXHRResponse(this.requests[2]); this.clock.tick(2 * 60 * 1000 - 1); assert.ok(media.excludeUntil - Date.now() > 0, 'original media still excluded'); this.clock.tick(1 * 60 * 1000); assert.equal( media.excludeUntil, Date.now(), 'media\'s exclude time reach to the current time' ); videojs.options.vhs = vhsOptions; }); QUnit.test('respects bandwidth option of 0', function(assert) { this.player.dispose(); this.player = createPlayer({ html5: { vhs: { bandwidth: 0 } } }); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); assert.equal(this.player.tech_.vhs.bandwidth, 0, 'set bandwidth to 0'); }); QUnit.test( 'uses default bandwidth option if non-numerical value provided', function(assert) { this.player.dispose(); this.player = createPlayer({ html5: { vhs: { bandwidth: 'garbage' } } }); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); assert.equal(this.player.tech_.vhs.bandwidth, 4194304, 'set bandwidth to default'); } ); QUnit.test('respects initialBandwidth option on the tech', function(assert) { this.player.dispose(); this.player = createPlayer({ html5: { initialBandwidth: 0 } }); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); assert.equal(this.player.tech_.vhs.bandwidth, 0, 'set bandwidth to 0'); }); QUnit.test('initialBandwidth option on the tech take precedence on over vhs bandwidth option', function(assert) { this.player.dispose(); this.player = createPlayer({ html5: { initialBandwidth: 0, vhs: { bandwidth: 100 } } }); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); assert.equal(this.player.tech_.vhs.bandwidth, 0, 'set bandwidth to 0'); }); QUnit.test('uses default bandwidth if browser is Android', function(assert) { this.player.dispose(); const origIsAndroid = videojs.browser.IS_ANDROID; videojs.browser.IS_ANDROID = false; this.player = createPlayer(); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); assert.equal( this.player.tech_.vhs.bandwidth, 4194304, 'set bandwidth to desktop default' ); this.player.dispose(); videojs.browser.IS_ANDROID = true; this.player = createPlayer(); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); assert.equal( this.player.tech_.vhs.bandwidth, 4194304, 'set bandwidth to mobile default' ); videojs.browser.IS_ANDROID = origIsAndroid; }); QUnit.test('does not break if the playlist has no segments', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); try { openMediaSource(this.player, this.clock); this.requests[0].respond( 200, null, '#EXTM3U\n' + '#EXT-X-PLAYLIST-TYPE:VOD\n' + '#EXT-X-TARGETDURATION:10\n' ); } catch (e) { assert.ok(false, 'an error was thrown'); throw e; } assert.ok(true, 'no error was thrown'); assert.strictEqual( this.requests.length, 1, 'no this.requestsfor non-existent segments were queued' ); }); QUnit.test('can seek before the source buffer opens', function(assert) { this.player.src({ src: 'media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); this.player.tech_.triggerReady(); this.clock.tick(1); this.standardXHRResponse(this.requests.shift()); this.player.triggerReady(); this.player.currentTime(1); assert.equal(this.player.currentTime(), 1, 'seeked'); }); QUnit.test('resets the switching algorithm if a request times out', function(assert) { this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 1e20; // main this.standardXHRResponse(this.requests.shift()); // media.m3u8 this.standardXHRResponse(this.requests.shift()); const segmentRequest = this.requests.shift(); assert.notOk(segmentRequest.timedout, 'request not timed out'); // simulate a segment timeout this.clock.tick(45001); assert.ok(segmentRequest.timedout, 'request timed out'); // new media this.standardXHRResponse(this.requests.shift()); assert.strictEqual( this.player.tech_.vhs.playlists.media(), this.player.tech_.vhs.playlists.main.playlists[1], 'reset to the lowest bitrate playlist' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 1, 'bandwidth is reset too'); }); QUnit.test('disposes the playlist loader', function(assert) { let disposes = 0; const player = createPlayer(); player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(player, this.clock); const loaderDispose = player.tech_.vhs.playlists.dispose; player.tech_.vhs.playlists.dispose = function() { disposes++; loaderDispose.call(player.tech_.vhs.playlists); }; player.dispose(); assert.strictEqual(disposes, 1, 'disposed playlist loader'); }); QUnit.test('remove event handlers on dispose', function(assert) { let unscoped = 0; const player = createPlayer(); const origPlayerOn = player.on.bind(player); const origPlayerOff = player.off.bind(player); player.on = function(...args) { if (typeof args[0] !== 'object') { unscoped++; } origPlayerOn(...args); }; player.off = function(...args) { if (typeof args[0] !== 'object') { unscoped--; } origPlayerOff(...args); }; player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(player, this.clock); this.standardXHRResponse(this.requests[0]); this.standardXHRResponse(this.requests[1]); assert.ok(unscoped > 0, 'has unscoped handlers'); player.dispose(); assert.ok(unscoped <= 0, 'no unscoped handlers'); }); QUnit.test('the source handler supports HLS mime types', function(assert) { assert.ok(VhsSourceHandler.canHandleSource({ type: 'aPplicatiOn/x-MPegUrl' }), 'supports x-mpegurl'); assert.ok(VhsSourceHandler.canHandleSource({ type: 'aPplicatiOn/VnD.aPPle.MpEgUrL' }), 'supports vnd.apple.mpegurl'); assert.ok( VhsSourceHandler.canPlayType('aPplicatiOn/VnD.aPPle.MpEgUrL'), 'supports vnd.apple.mpegurl' ); assert.ok( VhsSourceHandler.canPlayType('aPplicatiOn/x-MPegUrl'), 'supports x-mpegurl' ); }); QUnit.test('the source handler supports DASH mime types', function(assert) { assert.ok(VhsSourceHandler.canHandleSource({ type: 'aPplication/dAsh+xMl' }), 'supports application/dash+xml'); assert.ok( VhsSourceHandler.canPlayType('aPpLicAtion/DaSh+XmL'), 'supports application/dash+xml' ); }); QUnit.test( 'the source handler does not support non HLS/DASH mime types', function(assert) { assert.ok(!(VhsSourceHandler.canHandleSource({ type: 'video/mp4' }) instanceof VhsHandler), 'does not support mp4'); assert.ok(!(VhsSourceHandler.canHandleSource({ type: 'video/x-flv' }) instanceof VhsHandler), 'does not support flv'); assert.ok( !(VhsSourceHandler.canPlayType('video/mp4')), 'does not support mp4' ); assert.ok( !(VhsSourceHandler.canPlayType('video/x-flv')), 'does not support flv' ); } ); QUnit.test('has no effect if native HLS is available and browser is Safari', function(assert) { const Html5 = videojs.getTech('Html5'); const oldHtml5CanPlaySource = Html5.canPlaySource; const origIsAnySafari = videojs.browser.IS_ANY_SAFARI; videojs.browser.IS_ANY_SAFARI = true; Html5.canPlaySource = () => true; Vhs.supportsNativeHls = true; const player = createPlayer(); player.src({ src: 'http://example.com/manifest/main.m3u8', type: 'application/x-mpegURL' }); this.clock.tick(1); assert.ok(!player.tech_.vhs, 'did not load vhs tech'); player.dispose(); Html5.canPlaySource = oldHtml5CanPlaySource; videojs.browser.IS_ANY_SAFARI = origIsAnySafari; }); 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(); player.src({ src: 'http://example.com/manifest/main.m3u8', type: 'application/x-mpegURL' }); this.clock.tick(1); 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( 'loads if native HLS is available and override is set locally', function(assert) { let player; Vhs.supportsNativeHls = true; player = createPlayer({html5: {vhs: {overrideNative: true}}}); this.clock.tick(1); player.tech_.featuresNativeVideoTracks = true; player.src({ src: 'http://example.com/manifest/main.m3u8', type: 'application/x-mpegURL' }); this.clock.tick(1); assert.ok(player.tech_.vhs, 'did load vhs tech'); player.dispose(); player = createPlayer({html5: {vhs: {overrideNative: true}}}); this.clock.tick(1); player.tech_.featuresNativeVideoTracks = false; player.tech_.featuresNativeAudioTracks = false; player.src({ src: 'http://example.com/manifest/main.m3u8', type: 'application/x-mpegURL' }); this.clock.tick(1); assert.ok(player.tech_.vhs, 'did load vhs tech'); player.dispose(); } ); QUnit.test( 'loads if native HLS is available and override is set globally', function(assert) { videojs.options.vhs.overrideNative = true; let player; Vhs.supportsNativeHls = true; player = createPlayer(); player.tech_.featuresNativeVideoTracks = true; player.src({ src: 'http://example.com/manifest/main.m3u8', type: 'application/x-mpegURL' }); this.clock.tick(1); assert.ok(player.tech_.vhs, 'did load vhs tech'); player.dispose(); player = createPlayer(); player.tech_.featuresNativeVideoTracks = false; player.tech_.featuresNativeAudioTracks = false; player.src({ src: 'http://example.com/manifest/main.m3u8', type: 'application/x-mpegURL' }); this.clock.tick(1); assert.ok(player.tech_.vhs, 'did load vhs tech'); player.dispose(); } ); QUnit.test('re-emits mediachange events', function(assert) { let mediaChanges = 0; this.player.on('mediachange', function() { mediaChanges++; }); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.standardXHRResponse(this.requests.shift()); this.player.tech_.vhs.playlists.trigger('mediachange'); assert.strictEqual(mediaChanges, 1, 'fired mediachange'); }); QUnit.test('can be disposed before finishing initialization', function(assert) { const readyHandlers = []; this.player.ready = function(callback) { readyHandlers.push(callback); }; this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); readyHandlers.shift().call(this.player); this.player.src({ src: 'http://example.com/media.mp4', type: 'video/mp4' }); assert.ok(readyHandlers.length > 0, 'registered a ready handler'); try { while (readyHandlers.length) { readyHandlers.shift().call(this.player); openMediaSource(this.player, this.clock); } assert.ok(true, 'did not throw an exception'); } catch (e) { assert.ok(false, 'threw an exception'); } }); QUnit.test('calling play() at the end of a video replays', function(assert) { const done = assert.async(); let seekTime = -1; this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.setCurrentTime = function(time) { if (typeof time !== 'undefined') { seekTime = time; } return 0; }; this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXTINF:10,\n' + '0.ts\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); const segment = muxedSegment(); // copy the byte length since the segment bytes get cleared out const segmentByteLength = segment.byteLength; this.player.tech(true).vhs.playlistController_.mainSegmentLoader_.one('appending', () => { this.player.tech_.ended = function() { return true; }; this.player.tech_.trigger('play'); this.clock.tick(1); assert.equal(seekTime, 0, 'seeked to the beginning'); // verify stats assert.equal( this.player.tech_.vhs.stats.mediaBytesTransferred, segmentByteLength, 'transferred segment bytes' ); assert.equal(this.player.tech_.vhs.stats.mediaRequests, 1, '1 request'); done(); }); assert.ok(segmentByteLength, 'the segment has some number of bytes'); // segment 0 this.standardXHRResponse(this.requests.shift(), segment); }); QUnit.test('keys are resolved relative to the main playlist', function(assert) { this.player.src({ src: 'video/main-encrypted.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + 'playlist/playlist.m3u8\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-TARGETDURATION:15\n' + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence1.ts\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); assert.equal(this.requests.length, 2, 'requested the key'); assert.equal( this.requests[0].url, absoluteUrl('video/playlist/keys/key.php'), 'resolves multiple relative paths' ); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 4194304, 'default'); }); QUnit.test('keys are resolved relative to their containing playlist', function(assert) { this.player.src({ src: 'video/media-encrypted.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-TARGETDURATION:15\n' + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence1.ts\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); assert.equal(this.requests.length, 2, 'requested a key'); assert.equal( this.requests[0].url, absoluteUrl('video/keys/key.php'), 'resolves multiple relative paths' ); }); QUnit.test('keys are not requested when cached key available, cacheEncryptionKeys:true', function(assert) { this.player.src({ src: 'video/media-encrypted.m3u8', type: 'application/vnd.apple.mpegurl', cacheEncryptionKeys: true }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-TARGETDURATION:15\n' + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php",IV=0x00000000000000000000000000000000\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence1.ts\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence2.ts\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); assert.equal(this.requests.length, 2, 'requested a key'); assert.equal( this.requests[0].url, absoluteUrl('video/keys/key.php'), 'requested the key' ); assert.equal( this.requests[1].url, 'http://media.example.com/fileSequence1.ts', 'requested the segment' ); // key response this.standardXHRResponse(this.requests.shift(), encryptionKey()); const pc = this.player.tech_.vhs.playlistController_; return requestAndAppendSegment({ request: this.requests.shift(), mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock, segment: encryptedSegment(), decryptionTicks: true }).then(() => { assert.equal(this.requests.length, 1, 'requested a segment, not a key'); assert.equal( this.requests[0].url, absoluteUrl('http://media.example.com/fileSequence2.ts'), 'requested the segment only' ); }); }); QUnit.test('keys are requested per segment, cacheEncryptionKeys:false', function(assert) { this.player.src({ src: 'video/media-encrypted.m3u8', type: 'application/vnd.apple.mpegurl', cacheEncryptionKeys: false }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-TARGETDURATION:15\n' + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php",IV=0x00000000000000000000000000000000\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence1.ts\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence2.ts\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); assert.equal(this.requests.length, 2, 'requested a key'); assert.equal( this.requests[0].url, absoluteUrl('video/keys/key.php'), 'requested the key' ); assert.equal( this.requests[1].url, 'http://media.example.com/fileSequence1.ts', 'requested the segment' ); // key response this.standardXHRResponse(this.requests.shift(), encryptionKey()); const pc = this.player.tech_.vhs.playlistController_; return requestAndAppendSegment({ request: this.requests.shift(), mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock, segment: encryptedSegment(), decryptionTicks: true }).then(() => { assert.equal(this.requests.length, 2, 'requested a segment and a key'); assert.equal( this.requests[0].url, absoluteUrl('video/keys/key.php'), 'requested the segment only' ); assert.equal( this.requests[1].url, 'http://media.example.com/fileSequence2.ts', 'requested the segment' ); }); }); QUnit.test( 'seeking should abort an outstanding key request and create a new one', function(assert) { this.player.src({ src: 'https://example.com/encrypted.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-TARGETDURATION:15\n' + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' + '#EXTINF:9,\n' + 'http://media.example.com/fileSequence1.ts\n' + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key2.php"\n' + '#EXTINF:9,\n' + 'http://media.example.com/fileSequence2.ts\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); // segment 1 this.standardXHRResponse(this.requests.pop()); this.player.currentTime(11); this.clock.tick(2); assert.ok(this.requests[0].aborted, 'the key XHR should be aborted'); // aborted key 1 this.requests.shift(); assert.equal(this.requests.length, 2, 'requested the new key'); assert.equal( this.requests[0].url, 'https://example.com/' + this.player.tech_.vhs.playlists.media().segments[1].key.uri, 'urls should match' ); } ); QUnit.test('switching playlists with an outstanding key request aborts request and ' + 'loads segment', function(assert) { const media = '#EXTM3U\n' + '#EXT-X-MEDIA-SEQUENCE:5\n' + '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence52-A.ts\n' + '#EXTINF:15.0,\n' + 'http://media.example.com/fileSequence52-B.ts\n' + '#EXT-X-ENDLIST\n'; this.player.src({ src: 'https://example.com/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.trigger('play'); this.clock.tick(1); // main playlist this.standardXHRResponse(this.requests.shift()); // media playlist this.requests.shift().respond(200, null, media); this.clock.tick(1); // first segment of the original media playlist this.standardXHRResponse(this.requests.pop()); assert.equal(this.requests.length, 1, 'key request only one outstanding'); const keyXhr = this.requests.shift(); assert.ok(!keyXhr.aborted, 'key request outstanding'); this.player.tech_.vhs.playlists.trigger('mediachanging'); this.player.tech_.vhs.playlists.trigger('mediachange'); this.clock.tick(1); assert.ok(keyXhr.aborted, 'key request aborted'); assert.equal(this.requests.length, 2, 'loaded key and segment'); assert.equal( this.requests[0].url, 'https://priv.example.com/key.php?r=52', 'requested the key' ); assert.equal( this.requests[1].url, 'http://media.example.com/fileSequence52-A.ts', 'requested the segment' ); }); QUnit.test('does not download anything until play if preload option set to none', function(assert) { this.player.preload('none'); this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.clock.tick(10 * 1000); assert.equal(this.requests.length, 0, 'did not download any segments'); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 4194304, 'default'); this.player.tech_.paused = () => false; this.player.tech_.trigger('play'); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); assert.equal(this.requests.length, 1, 'requested segment'); }); // workaround https://bugzilla.mozilla.org/show_bug.cgi?id=548397 QUnit.test( 'selectPlaylist does not fail if getComputedStyle returns null', function(assert) { const oldGetComputedStyle = window.getComputedStyle; window.getComputedStyle = function() { return null; }; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); this.player.tech_.vhs.selectPlaylist(); assert.ok(true, 'should not throw'); window.getComputedStyle = oldGetComputedStyle; // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 4194304, 'default'); } ); QUnit.test('resolves relative key URLs against the playlist', function(assert) { this.player.src({ src: 'https://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.requests.shift() .respond( 200, null, '#EXTM3U\n' + '#EXT-X-MEDIA-SEQUENCE:5\n' + '#EXT-X-KEY:METHOD=AES-128,URI="key.php?r=52"\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence52-A.ts\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); assert.equal( this.requests[0].url, 'https://example.com/key.php?r=52', 'resolves the key URL' ); }); QUnit.test( 'adds 1 default audio track if we have not parsed any and the playlist is loaded', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); assert.equal(this.player.audioTracks().length, 0, 'zero audio tracks at load time'); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); assert.equal(this.player.audioTracks().length, 1, 'one audio track after load'); assert.equal(this.player.audioTracks()[0].label, 'default', 'set the label'); } ); QUnit.test('adds audio tracks if we have parsed some from a playlist', function(assert) { this.player.src({ src: 'manifest/multipleAudioGroups.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); assert.equal(this.player.audioTracks().length, 0, 'zero audio tracks at load time'); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); const vjsAudioTracks = this.player.audioTracks(); assert.equal(vjsAudioTracks.length, 3, '3 active vjs tracks'); assert.equal(vjsAudioTracks[0].enabled, true, 'default track is enabled'); vjsAudioTracks[1].enabled = true; assert.equal(vjsAudioTracks[1].enabled, true, 'new track is enabled on vjs'); assert.equal(vjsAudioTracks[0].enabled, false, 'main track is disabled'); }); QUnit.test('cleans up the buffer when loading live segments', function(assert) { const seekable = createTimeRanges([[0, 70]]); this.player.src({ src: 'liveStart30sBefore.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.playlistController_.seekable = function() { return seekable; }; this.player.tech_.vhs.bandwidth = 20e10; this.player.tech_.readyState = () => 4; this.player.tech_.triggerReady(); // media this.standardXHRResponse(this.requests[0]); this.player.tech_.vhs.playlists.trigger('loadedmetadata'); this.player.tech_.trigger('canplay'); this.player.tech_.paused = function() { return false; }; this.player.tech_.trigger('play'); this.clock.tick(1); const pc = this.player.tech_.vhs.playlistController_; const audioRemoves = []; const videoRemoves = []; // request first playable segment return requestAndAppendSegment({ request: this.requests[1], mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock }).then(() => { const audioBuffer = pc.sourceUpdater_.audioBuffer; const videoBuffer = pc.sourceUpdater_.videoBuffer; const origAudioRemove = audioBuffer.remove.bind(audioBuffer); const origVideoRemove = videoBuffer.remove.bind(videoBuffer); audioBuffer.remove = (start, end) => { audioRemoves.push({start, end}); origAudioRemove(); }; videoBuffer.remove = (start, end) => { videoRemoves.push({start, end}); origVideoRemove(); }; // since source buffers are mocked, must fake that there's buffered data, or else we // don't bother processing removes audioBuffer.buffered = createTimeRanges([[10, 20]]); videoBuffer.buffered = createTimeRanges([[15, 25]]); // request second segment, and give enough time for the source buffer to process removes return requestAndAppendSegment({ request: this.requests[2], mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock }); }).then(() => { assert.equal(audioRemoves.length, 1, 'one audio remove'); assert.equal(videoRemoves.length, 1, 'one video remove'); // segment-loader removes at currentTime - 30 assert.deepEqual( audioRemoves[0], { start: 0, end: 40 }, 'removed from audio buffer with right range' ); assert.deepEqual( videoRemoves[0], { start: 0, end: 40 }, 'removed from video buffer with right range' ); }); }); QUnit.test('cleans up buffer by removing targetDuration from currentTime when loading a ' + 'live segment if seekable start is after currentTime', function(assert) { let seekable = createTimeRanges([[0, 80]]); this.player.src({ src: 'liveStart30sBefore.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); this.player.tech_.vhs.playlistController_.seekable = function() { return seekable; }; this.player.tech_.readyState = () => 4; this.player.tech_.vhs.bandwidth = 20e10; this.player.tech_.triggerReady(); // media this.standardXHRResponse(this.requests.shift()); this.player.tech_.vhs.playlists.trigger('loadedmetadata'); this.player.tech_.trigger('canplay'); this.player.tech_.paused = function() { return false; }; this.player.tech_.trigger('play'); this.clock.tick(1); const pc = this.player.tech_.vhs.playlistController_; const audioRemoves = []; const videoRemoves = []; // request first playable segment return requestAndAppendSegment({ request: this.requests.shift(), mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock }).then(() => { // Change seekable so that it starts *after* the currentTime which was set // based on the previous seekable range (the end of 80) seekable = createTimeRanges([[110, 120]]); this.clock.tick(1); const audioBuffer = pc.sourceUpdater_.audioBuffer; const videoBuffer = pc.sourceUpdater_.videoBuffer; const origAudioRemove = audioBuffer.remove.bind(audioBuffer); const origVideoRemove = videoBuffer.remove.bind(videoBuffer); audioBuffer.remove = (start, end) => { audioRemoves.push({start, end}); origAudioRemove(); }; videoBuffer.remove = (start, end) => { videoRemoves.push({start, end}); origVideoRemove(); }; // since source buffers are mocked, must fake that there's buffered data, or else we // don't bother processing removes audioBuffer.buffered = createTimeRanges([[10, 20]]); videoBuffer.buffered = createTimeRanges([[15, 25]]); // prevent trying to correct live time disposePlaybackWatcher(this.player); // request second segment, and give enough time for the source buffer to process removes return requestAndAppendSegment({ request: this.requests.shift(), mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock }); }).then(() => { assert.equal(audioRemoves.length, 1, 'one audio remove'); assert.equal(videoRemoves.length, 1, 'one video remove'); // segment-loader removes at currentTime - 30 assert.deepEqual( audioRemoves[0], { start: 0, end: 80 - 10 }, 'removed from audio buffer with right range' ); assert.deepEqual( videoRemoves[0], { start: 0, end: 80 - 10 }, 'removed from video buffer with right range' ); }); }); QUnit.test('cleans up the buffer when loading VOD segments', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.width(640); this.player.height(360); this.player.tech_.vhs.bandwidth = 20e10; // main this.standardXHRResponse(this.requests[0]); // media this.standardXHRResponse(this.requests[1]); const pc = this.player.tech_.vhs.playlistController_; const audioRemoves = []; const videoRemoves = []; // first segment request will set up all of the source buffers we need return requestAndAppendSegment({ request: this.requests[2], mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock }).then(() => { const audioBuffer = pc.sourceUpdater_.audioBuffer; const videoBuffer = pc.sourceUpdater_.videoBuffer; const origAudioRemove = audioBuffer.remove.bind(audioBuffer); const origVideoRemove = videoBuffer.remove.bind(videoBuffer); audioBuffer.remove = (start, end) => { audioRemoves.push({start, end}); window.setTimeout(() => audioBuffer.trigger('updateend'), 1); origAudioRemove(); }; videoBuffer.remove = (start, end) => { videoRemoves.push({start, end}); window.setTimeout(() => videoBuffer.trigger('updateend'), 1); origVideoRemove(); }; // the seek will have removed everything to the duration of the video, so we want to // only start tracking removes after the seek, once the next segment request is made this.player.currentTime(120); // since source buffers are mocked, must fake that there's buffered data, or else we // don't bother processing removes audioBuffer.buffered = createTimeRanges([[0, 10]]); videoBuffer.buffered = createTimeRanges([[1, 11]]); // This requires 2 clock ticks because after updateend monitorBuffer_ is called // to setup fillBuffer on the next tick, but the seek also causes monitorBuffer_ to be // called, which cancels the previously set timeout and sets a new one for the following // tick. this.clock.tick(2); assert.ok(this.requests[3].aborted, 'request aborted during seek'); // request second segment, and give enough time for the source buffer to process removes return requestAndAppendSegment({ request: this.requests[4], mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock }); }).then(() => { assert.ok(audioRemoves.length, 'audio removes'); assert.ok(videoRemoves.length, 'video removes'); // the default manifest is 4 segments that are 10s each. assert.deepEqual(audioRemoves, [ // The first remove comes from the setCurrentTime call, // caused by player.currentTime(120) { start: 0, end: 40 }, // The second remove comes from trimBackBuffer_ and is based on currentTime { start: 0, end: 120 - 30 }, // the final remove comes after our final requestAndAppendSegment // and happens because our guess to append to a buffered ranged near // currentTime is incorrect. { start: 0, end: 40 } ], 'removed from audio buffer with right range'); assert.deepEqual(videoRemoves, [ { start: 0, end: 40 }, { start: 0, end: 120 - 30 }, { start: 0, end: 40 } ], 'removed from audio buffer with right range'); }); }); QUnit.test('when mediaGroup changes enabled track should not change', function(assert) { let vhsAudioChangeEvents = 0; this.player.src({ src: 'manifest/multipleAudioGroups.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.on('usage', (event) => { if (event.name === 'vhs-audio-change') { vhsAudioChangeEvents++; } }); // main this.standardXHRResponse(this.requests.shift()); // video media this.standardXHRResponse(this.requests.shift()); const vhs = this.player.tech_.vhs; const pc = vhs.playlistController_; let audioTracks = this.player.audioTracks(); assert.equal(vhsAudioChangeEvents, 0, 'no vhs-audio-change event was fired'); assert.equal(audioTracks.length, 3, 'three audio tracks after load'); assert.equal(audioTracks[0].enabled, true, 'track one enabled after load'); let oldMediaGroup = vhs.playlists.media().attributes.AUDIO; // clear out any outstanding requests this.requests.length = 0; // force pc to select a playlist from a new media group pc.mainPlaylistLoader_.media(pc.main().playlists[0]); this.clock.tick(1); // video media this.standardXHRResponse(this.requests.shift()); assert.notEqual( oldMediaGroup, vhs.playlists.media().attributes.AUDIO, 'selected a new playlist' ); audioTracks = this.player.audioTracks(); const activeGroup = pc.mediaTypes_.AUDIO.activeGroup(audioTracks[0]); assert.equal(audioTracks.length, 3, 'three audio tracks after changing mediaGroup'); assert.ok(activeGroup.default, 'track one should be the default'); assert.ok(audioTracks[0].enabled, 'enabled the default track'); assert.notOk(audioTracks[1].enabled, 'disabled track two'); assert.notOk(audioTracks[2].enabled, 'disabled track three'); audioTracks[1].enabled = true; assert.notOk(audioTracks[0].enabled, 'disabled track one'); assert.ok(audioTracks[1].enabled, 'enabled track two'); assert.notOk(audioTracks[2].enabled, 'disabled track three'); oldMediaGroup = vhs.playlists.media().attributes.AUDIO; // clear out any outstanding requests this.requests.length = 0; // swap back to the old media group // this playlist is already loaded so no new requests are made pc.mainPlaylistLoader_.media(pc.main().playlists[3]); this.clock.tick(1); assert.notEqual( oldMediaGroup, vhs.playlists.media().attributes.AUDIO, 'selected a new playlist' ); audioTracks = this.player.audioTracks(); assert.equal(vhsAudioChangeEvents, 1, 'a vhs-audio-change event was fired'); assert.equal(audioTracks.length, 3, 'three audio tracks after reverting mediaGroup'); assert.notOk(audioTracks[0].enabled, 'the default track is still disabled'); assert.ok(audioTracks[1].enabled, 'track two is still enabled'); assert.notOk(audioTracks[2].enabled, 'track three is still disabled'); }); QUnit.test( 'Allows specifying the beforeRequest function on the player', function(assert) { let beforeRequestCalled = false; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.xhr.beforeRequest = function() { beforeRequestCalled = true; }; // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); assert.ok(beforeRequestCalled, 'beforeRequest was called'); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 4194304, 'default'); } ); QUnit.test('Allows specifying the beforeRequest function globally', function(assert) { let beforeRequestCalled = false; videojs.Vhs.xhr.beforeRequest = function() { beforeRequestCalled = true; }; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); assert.ok(beforeRequestCalled, 'beforeRequest was called'); delete videojs.Vhs.xhr.beforeRequest; // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 4194304, 'default'); }); QUnit.test('Allows specifying custom xhr() function globally', function(assert) { const originalXhr = videojs.Vhs.xhr; let customXhr = false; videojs.Vhs.xhr = function(opts, callback) { customXhr = true; return videojs.xhr(opts, function(err, response, body) { callback(err, response); }); }; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); assert.ok(customXhr, 'customXhr was called'); videojs.Vhs.xhr = originalXhr; // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 4194304, 'default'); }); QUnit.test('Allows overriding the global beforeRequest function', function(assert) { let beforeGlobalRequestCalled = 0; let beforeLocalRequestCalled = 0; videojs.Vhs.xhr.beforeRequest = function() { beforeGlobalRequestCalled++; }; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.xhr.beforeRequest = function() { beforeLocalRequestCalled++; }; // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); // ts this.standardXHRResponse(this.requests.shift(), muxedSegment()); assert.equal(beforeLocalRequestCalled, 2, 'local beforeRequest was called twice ' + 'for the media playlist and media'); assert.equal(beforeGlobalRequestCalled, 1, 'global beforeRequest was called once ' + 'for the main playlist'); delete videojs.Vhs.xhr.beforeRequest; }); QUnit.test('Allows setting onRequest hooks globally', function(assert) { let onRequestHookCallCount = 0; let actualRequestUrl; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const globalRequestHook1 = (request) => { const requestUrl = new URL(request.url); requestUrl.searchParams.set('foo', 'bar'); request.url = decodeURIComponent(requestUrl.href); actualRequestUrl = request.url; onRequestHookCallCount++; }; const globalRequestHook2 = () => { onRequestHookCallCount++; }; videojs.Vhs.xhr.onRequest(globalRequestHook1); videojs.Vhs.xhr.onRequest(globalRequestHook2); // main this.standardXHRResponse(this.requests.shift()); assert.equal(onRequestHookCallCount, 2, '2 onRequest hooks called'); assert.equal(actualRequestUrl, 'http://localhost:9999/test/media2.m3u8?foo=bar', 'request url modified by onRequest hook'); // remove global hooks for other tests videojs.Vhs.xhr.offRequest(globalRequestHook1); videojs.Vhs.xhr.offRequest(globalRequestHook2); }); QUnit.test('Allows setting onRequest hooks on the player', function(assert) { let onRequestHookCallCount = 0; let actualRequestUrl; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const playerRequestHook1 = (request) => { const requestUrl = new URL(request.url); requestUrl.searchParams.set('foo', 'bar'); request.url = decodeURIComponent(requestUrl.href); actualRequestUrl = request.url; onRequestHookCallCount++; }; const playerRequestHook2 = () => { onRequestHookCallCount++; }; // Setup player level xhr hooks. this.player.tech_.vhs.setupXhrHooks_(); this.player.tech_.vhs.xhr.onRequest(playerRequestHook1); this.player.tech_.vhs.xhr.onRequest(playerRequestHook2); // main this.standardXHRResponse(this.requests.shift()); assert.equal(onRequestHookCallCount, 2, '2 onRequest hooks called'); assert.equal(actualRequestUrl, 'http://localhost:9999/test/media2.m3u8?foo=bar', 'request url modified by onRequest hook'); // remove player hooks for other tests this.player.tech_.vhs.xhr.offRequest(playerRequestHook1); this.player.tech_.vhs.xhr.offRequest(playerRequestHook2); }); QUnit.test('Allows setting onRequest hooks globally and overriding with player hooks', function(assert) { let onRequestHookCallCountGlobal = 0; let onRequestHookCallCountPlayer = 0; let actualRequestUrlGlobal; let actualRequestUrlPlayer; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const globalRequestHook1 = (request) => { const requestUrl = new URL(request.url); requestUrl.searchParams.set('foo', 'bar'); request.url = decodeURIComponent(requestUrl.href); actualRequestUrlGlobal = request.url; onRequestHookCallCountGlobal++; }; const globalRequestHook2 = () => { onRequestHookCallCountGlobal++; }; videojs.Vhs.xhr.onRequest(globalRequestHook1); videojs.Vhs.xhr.onRequest(globalRequestHook2); const playerRequestHook1 = (request) => { const requestUrl = new URL(request.url); requestUrl.searchParams.set('bar', 'foo'); request.url = decodeURIComponent(requestUrl.href); actualRequestUrlPlayer = request.url; onRequestHookCallCountPlayer++; }; const playerRequestHook2 = () => { onRequestHookCallCountPlayer++; }; // Setup player level xhr hooks. this.player.tech_.vhs.setupXhrHooks_(); this.player.tech_.vhs.xhr.onRequest(playerRequestHook1); this.player.tech_.vhs.xhr.onRequest(playerRequestHook2); // main this.standardXHRResponse(this.requests.shift()); // remove player request hooks this.player.tech_.vhs.xhr.offRequest(playerRequestHook1); this.player.tech_.vhs.xhr.offRequest(playerRequestHook2); assert.equal(onRequestHookCallCountGlobal, 0, 'no onRequest global hooks called'); assert.equal(actualRequestUrlGlobal, undefined, 'global request url undefined'); assert.equal(onRequestHookCallCountPlayer, 2, '2 onRequest player hooks called'); assert.equal(actualRequestUrlPlayer, 'http://localhost:9999/test/media2.m3u8?bar=foo', 'request url modified by player onRequest hook'); // media this.standardXHRResponse(this.requests.shift()); assert.equal(onRequestHookCallCountGlobal, 2, '2 onRequest global hooks called'); assert.equal(actualRequestUrlGlobal, 'http://localhost:9999/test/media2-00001.ts?foo=bar', 'request url modified by global onRequest hook'); videojs.Vhs.xhr.offRequest(globalRequestHook1); videojs.Vhs.xhr.offRequest(globalRequestHook2); }); QUnit.test('Allows removing onRequest hooks globally with offRequest', function(assert) { let onRequestHookCallCount = 0; let actualRequestUrl; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const globalRequestHook1 = (request) => { const requestUrl = new URL(request.url); requestUrl.searchParams.set('foo', 'bar'); request.url = decodeURIComponent(requestUrl.href); actualRequestUrl = request.url; onRequestHookCallCount++; }; const globalRequestHook2 = () => { onRequestHookCallCount++; }; videojs.Vhs.xhr.onRequest(globalRequestHook1); videojs.Vhs.xhr.onRequest(globalRequestHook2); // main this.standardXHRResponse(this.requests.shift()); assert.equal(onRequestHookCallCount, 2, '2 onRequest hooks called'); assert.equal(actualRequestUrl, 'http://localhost:9999/test/media2.m3u8?foo=bar', 'request url modified by onRequest hook'); videojs.Vhs.xhr.offRequest(globalRequestHook1); // media this.standardXHRResponse(this.requests.shift()); assert.equal(onRequestHookCallCount, 3, '3 onRequest hooks called'); }); QUnit.test('Allows setting onResponse hooks globally', function(assert) { const done = assert.async(); let onResponseHookCallCount = 0; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const globalResponseHook1 = (request, error, response) => { onResponseHookCallCount++; }; const globalResponseHook2 = (request, error, response) => { assert.equal(onResponseHookCallCount, 1, '1 onResponse hook called'); assert.equal(response.url, 'http://localhost:9999/test/media2.m3u8', 'got expected response url'); done(); }; videojs.Vhs.xhr.onResponse(globalResponseHook1); videojs.Vhs.xhr.onResponse(globalResponseHook2); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); videojs.Vhs.xhr.offResponse(globalResponseHook1); videojs.Vhs.xhr.offResponse(globalResponseHook2); }); QUnit.test('Allows setting onResponse hooks on the player', function(assert) { const done = assert.async(); let onResponseHookCallCount = 0; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const globalResponseHook1 = (request, error, response) => { onResponseHookCallCount++; }; const globalResponseHook2 = (request, error, response) => { assert.equal(onResponseHookCallCount, 1, '1 onResponse hook called'); assert.equal(response.url, 'http://localhost:9999/test/media2.m3u8', 'got expected response url'); done(); }; // Setup player level xhr hooks. this.player.tech_.vhs.setupXhrHooks_(); this.player.tech_.vhs.xhr.onResponse(globalResponseHook1); this.player.tech_.vhs.xhr.onResponse(globalResponseHook2); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); this.player.tech_.vhs.xhr.offResponse(globalResponseHook1); this.player.tech_.vhs.xhr.offResponse(globalResponseHook2); }); QUnit.test('Allows setting onResponse hooks globally and overriding with player hooks', function(assert) { const done = assert.async(); let onResponseHookCallCountGlobal = 0; let onResponseHookCallCountplayer = 0; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const globalResponseHook1 = (request, error, response) => { onResponseHookCallCountGlobal++; }; const globalResponseHook2 = (request, error, response) => { assert.equal(onResponseHookCallCountGlobal, 1, 'no global onResponse hook called'); assert.equal(response.url, 'http://localhost:9999/test/media2-00001.ts', 'got expected response url'); done(); }; const playerResponseHook1 = (request, error, response) => { onResponseHookCallCountplayer++; }; const playerResponseHook2 = (request, error, response) => { assert.equal(onResponseHookCallCountGlobal, 0, 'no global onResponse hook called'); assert.equal(onResponseHookCallCountplayer, 1, '1 player onResponse hook called'); assert.equal(response.url, 'http://localhost:9999/test/media2.m3u8', 'got expected response url'); }; // Setup player level xhr hooks. this.player.tech_.vhs.setupXhrHooks_(); videojs.Vhs.xhr.onResponse(globalResponseHook1); videojs.Vhs.xhr.onResponse(globalResponseHook2); this.player.tech_.vhs.xhr.onResponse(playerResponseHook1); this.player.tech_.vhs.xhr.onResponse(playerResponseHook2); // main this.standardXHRResponse(this.requests.shift()); this.player.tech_.vhs.xhr.offResponse(playerResponseHook1); this.player.tech_.vhs.xhr.offResponse(playerResponseHook2); // media this.standardXHRResponse(this.requests.shift()); // ts this.standardXHRResponse(this.requests.shift(), muxedSegment()); videojs.Vhs.xhr.offResponse(globalResponseHook1); videojs.Vhs.xhr.offResponse(globalResponseHook2); }); QUnit.test('Allows removing onResponse hooks globally with offResponse', function(assert) { const done = assert.async(); let onResponseHookCallCount = 0; this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); const globalResponseHook1 = (request, error, response) => { onResponseHookCallCount++; }; const globalResponseHook2 = (request, error, response) => { assert.equal(onResponseHookCallCount, 0, '0 onResponse hooks called'); assert.equal(response.url, 'http://localhost:9999/test/media2.m3u8', 'got expected response url'); done(); }; videojs.Vhs.xhr.onResponse(globalResponseHook1); videojs.Vhs.xhr.onResponse(globalResponseHook2); // remove hook1 videojs.Vhs.xhr.offResponse(globalResponseHook1); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); videojs.Vhs.xhr.offResponse(globalResponseHook2); }); QUnit.test( 'passes useCueTags vhs option to main playlist controller', function(assert) { this.player.src({ src: 'main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); assert.ok( !this.player.tech_.vhs.playlistController_.useCueTags_, 'useCueTags is falsy by default' ); const origVhsOptions = videojs.options.vhs; videojs.options.vhs = { useCueTags: true }; this.player.dispose(); this.player = createPlayer(); this.player.src({ src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); assert.ok( this.player.tech_.vhs.playlistController_.useCueTags_, 'useCueTags passed to main playlist controller' ); videojs.options.vhs = origVhsOptions; } ); QUnit.test('populates quality levels list when available', function(assert) { this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); assert.ok(this.player.tech_.vhs.qualityLevels_, 'added quality levels'); const qualityLevels = this.player.qualityLevels(); let addCount = 0; let changeCount = 0; qualityLevels.on('addqualitylevel', () => { addCount++; }); qualityLevels.on('change', () => { changeCount++; }); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); assert.equal(addCount, 4, 'four levels added from main'); assert.equal(changeCount, 1, 'selected initial quality level'); this.player.dispose(); this.player = createPlayer({}, { src: 'http://example.com/media.m3u8', type: 'application/vnd.apple.mpegurl' }, this.clock); openMediaSource(this.player, this.clock); assert.ok( this.player.tech_.vhs.qualityLevels_, 'added quality levels from video with source' ); }); QUnit.test('configures eme for DASH on source buffer creation', function(assert) { this.player.eme = { options: { previousSetting: 1 } }; this.player.src({ src: 'manifest/main.mpd', type: 'application/dash+xml', keySystems: { keySystem1: { url: 'url1' } } }); this.clock.tick(1); const media = { attributes: { CODECS: 'avc1.420015' }, contentProtection: { keySystem1: { pssh: 'test' } } }; this.player.tech_.vhs.playlists = { main: { playlists: [media] }, media: () => media }; this.player.tech_.vhs.playlistController_.mediaTypes_ = { SUBTITLES: {}, AUDIO: { activePlaylistLoader: { media: () => { return { attributes: { CODECS: 'mp4a.40.2c' } }; } } } }; this.player.tech_.vhs.playlistController_.sourceUpdater_.trigger('createdsourcebuffers'); assert.deepEqual(this.player.eme.options, { previousSetting: 1 }, 'did not modify plugin options'); assert.deepEqual(this.player.currentSource(), { src: 'manifest/main.mpd', type: 'application/dash+xml', keySystems: { keySystem1: { url: 'url1', audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"', pssh: 'test' } } }, 'set source eme options'); }); QUnit.test('configures eme for HLS on source buffer creation', function(assert) { this.player.eme = { options: { previousSetting: 1 } }; this.player.src({ src: 'manifest/main.m3u8', type: 'application/x-mpegURL', keySystems: { keySystem1: { url: 'url1' } } }); this.clock.tick(1); const media = { attributes: { CODECS: 'avc1.420015, mp4a.40.2c' }, contentProtection: { keySystem1: { pssh: 'test' } } }; this.player.tech_.vhs.playlists = { main: { playlists: [media] }, media: () => media }; this.player.tech_.vhs.playlistController_.sourceUpdater_.trigger('createdsourcebuffers'); assert.deepEqual(this.player.eme.options, { previousSetting: 1 }, 'did not modify plugin options'); assert.deepEqual(this.player.currentSource(), { src: 'manifest/main.m3u8', type: 'application/x-mpegURL', keySystems: { keySystem1: { url: 'url1', audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"', pssh: 'test' } } }, 'set source eme options'); }); QUnit.test('eme handles keystatuschange where status is output-restricted', function(assert) { const originalWarn = videojs.log.warn; let warning = ''; let qualitySwitches = 0; videojs.log.warn = (...text) => { warning += [...text].join(''); }; this.player.eme = { options: { previousSetting: 1 } }; this.player.src({ src: 'manifest/main.m3u8', type: 'application/x-mpegURL', keySystems: { keySystem1: { url: 'url1' } } }); this.clock.tick(1); const playlists = [ { attributes: { RESOLUTION: { width: 1280, height: 720 } } }, { attributes: { RESOLUTION: { width: 1920, height: 1080 } } }, { attributes: { RESOLUTION: { width: 848, height: 480 } } } ]; this.player.tech_.vhs.playlists = { main: { playlists }, media: () => playlists[0] }; this.player.tech_.vhs.playlistController_.main = () => { return { playlists }; }; this.player.tech_.vhs.playlistController_.fastQualityChange_ = () => { qualitySwitches++; }; this.player.tech_.vhs.playlistController_.sourceUpdater_.trigger('createdsourcebuffers'); this.player.tech_.trigger({type: 'keystatuschange', status: 'output-restricted'}); assert.equal(playlists[0].excludeUntil, Infinity, 'first HD playlist excluded'); assert.equal(playlists[1].excludeUntil, Infinity, 'second HD playlist excluded'); assert.equal(playlists[2].excludeUntil, undefined, 'non-HD playlist not excluded'); assert.equal(qualitySwitches, 1, 'fastQualityChange_ called once'); assert.equal( warning, 'DRM keystatus changed to "output-restricted." Removing the following HD playlists ' + 'that will most likely fail to play and clearing the buffer. ' + 'This may be due to HDCP restrictions on the stream and the capabilities of the current device.' + [playlists[0], playlists[1]].join('') ); videojs.log.warn = originalWarn; }); QUnit.test('eme handles keystatuschange where status is usable', function(assert) { this.player.eme = { options: { previousSetting: 1 } }; this.player.src({ src: 'manifest/main.m3u8', type: 'application/x-mpegURL', keySystems: { keySystem1: { url: 'url1' } } }); this.clock.tick(1); const media = { attributes: { CODECS: 'avc1.420015, mp4a.40.2c' }, contentProtection: { keySystem1: { pssh: 'test' } } }; this.player.tech_.vhs.playlists = { main: { playlists: [media] }, media: () => media }; const excludes = []; this.player.tech_.vhs.playlistController_.excludePlaylist = (exclude) => { excludes.push(exclude); }; this.player.tech_.vhs.playlistController_.sourceUpdater_.trigger('createdsourcebuffers'); this.player.tech_.trigger({type: 'keystatuschange', status: 'usable'}); assert.deepEqual(excludes, [], 'did not exclude anything'); }); QUnit.test('eme waitingforkey event triggers another setup', function(assert) { this.player.eme = { options: { setting: 1 } }; this.player.src({ src: 'manifest/main.m3u8', type: 'application/x-mpegURL', keySystems: { keySystem1: { url: 'url1' } } }); this.clock.tick(1); const media = { attributes: { CODECS: 'avc1.420015, mp4a.40.2c' }, contentProtection: { keySystem1: { pssh: 'test' } } }; const vhs = this.player.tech_.vhs; vhs.playlists = { main: { playlists: [media] }, media: () => media }; const origCreateKeySessions = vhs.createKeySessions_.bind(vhs); let createKeySessionCalls = 0; vhs.createKeySessions_ = () => { createKeySessionCalls++; origCreateKeySessions(); }; vhs.playlistController_.sourceUpdater_.trigger('createdsourcebuffers'); assert.equal(createKeySessionCalls, 1, 'called createKeySessions_ once'); this.player.tech_.trigger({type: 'waitingforkey', status: 'usable'}); assert.equal(createKeySessionCalls, 2, 'called createKeySessions_ again'); }); QUnit.test('integration: configures eme for DASH on source buffer creation', function(assert) { assert.timeout(3000); const done = assert.async(); this.player.eme = { options: { previousSetting: 1 } }; this.player.src({ src: 'dash.mpd', type: 'application/dash+xml', keySystems: { keySystem1: { url: 'url1' } } }); openMediaSource(this.player, this.clock); this.player.tech_.vhs.playlistController_.sourceUpdater_.on( 'createdsourcebuffers', () => { assert.deepEqual(this.player.eme.options, { previousSetting: 1 }, 'did not modify plugin options'); assert.deepEqual(this.player.currentSource(), { src: 'dash.mpd', type: 'application/dash+xml', keySystems: { keySystem1: { url: 'url1', audioContentType: 'audio/mp4;codecs="mp4a.40.2"', videoContentType: 'video/mp4;codecs="avc1.420015"' } } }, 'set source eme options'); done(); } ); this.standardXHRResponse(this.requests[0]); // this allows the audio playlist loader to load this.clock.tick(1); // respond to segement request to get trackinfo this.standardXHRResponse(this.requests[1], mp4VideoInitSegment()); this.standardXHRResponse(this.requests[2], mp4VideoSegment()); this.standardXHRResponse(this.requests[3], mp4AudioInitSegment()); this.standardXHRResponse(this.requests[4], mp4AudioSegment()); }); QUnit.test('integration: configures eme for HLS on source buffer creation', function(assert) { assert.timeout(3000); const done = assert.async(); this.player.eme = { options: { previousSetting: 1 } }; this.player.src({ src: 'demuxed-two.m3u8', type: 'application/x-mpegURL', keySystems: { keySystem1: { url: 'url1' } } }); openMediaSource(this.player, this.clock); this.player.tech_.vhs.playlistController_.sourceUpdater_.on( 'createdsourcebuffers', () => { assert.deepEqual(this.player.eme.options, { previousSetting: 1 }, 'did not modify plugin options'); assert.deepEqual(this.player.currentSource(), { src: 'demuxed-two.m3u8', type: 'application/x-mpegURL', keySystems: { keySystem1: { url: 'url1', audioContentType: 'audio/mp4;codecs="mp4a.40.2"', videoContentType: 'video/mp4;codecs="avc1.420015"' } } }, 'set source eme options'); done(); } ); // main manifest this.standardXHRResponse(this.requests.shift()); // video manifest this.standardXHRResponse(this.requests.shift()); // audio manifest this.standardXHRResponse(this.requests.shift()); // this allows the audio playlist loader to load this.clock.tick(1); // respond to segement request to get trackinfo this.standardXHRResponse(this.requests.shift(), videoSegment()); this.standardXHRResponse(this.requests.shift(), audioSegment()); }); QUnit.test('integration: updates source updater after eme init', function(assert) { assert.expect(4); assert.timeout(3000); const done = assert.async(); this.player.src({ src: 'demuxed-two.m3u8', type: 'application/x-mpegURL', keySystems: { keySystem1: { url: 'url1' } } }); openMediaSource(this.player, this.clock); const sourceUpdater = this.player.tech_.vhs.playlistController_.sourceUpdater_; sourceUpdater.on('ready', () => { assert.ok(sourceUpdater.hasInitializedAnyEme(), 'updated source updater'); done(); }); sourceUpdater.on( 'createdsourcebuffers', () => { const expected = false; assert.equal(sourceUpdater.hasInitializedAnyEme(), expected, 'correct eme state'); } ); // main manifest this.standardXHRResponse(this.requests.shift()); // video manifest this.standardXHRResponse(this.requests.shift()); // audio manifest this.standardXHRResponse(this.requests.shift()); // this allows the audio playlist loader to load this.clock.tick(1); // respond to segement request to get trackinfo this.standardXHRResponse(this.requests.shift(), videoSegment()); this.standardXHRResponse(this.requests.shift(), audioSegment()); }); QUnit.test('player error when key session creation rejects promise', function(assert) { const done = assert.async(); this.player.error = (errorObject) => { assert.deepEqual( errorObject, { code: 3, message: 'Failed to initialize media keys for EME' }, 'called player error with correct error' ); done(); }; this.player.eme = { initializeMediaKeys: (keySystems, callback) => { // calling back with an error should lead to promise rejection callback({ message: 'this is the error message' }); } }; this.player.src({ src: 'manifest/main.mpd', type: 'application/dash+xml', keySystems: { keySystem1: { url: 'url1' } } }); this.clock.tick(1); const media = { attributes: { CODECS: 'avc1.420015' }, contentProtection: { keySystem1: { pssh: 'test' } } }; this.player.tech_.vhs.playlists = { main: { playlists: [media] }, media: () => media }; this.player.tech_.vhs.playlistController_.mediaTypes_ = { SUBTITLES: {}, AUDIO: {} }; this.player.tech_.vhs.playlistController_.sourceUpdater_.trigger('createdsourcebuffers'); }); QUnit.test( 'does not set source keySystems if keySystems not provided by source', function(assert) { this.player.src({ src: 'manifest/main.mpd', type: 'application/dash+xml' }); this.clock.tick(1); this.player.tech_.vhs.playlists = { media: () => { return { attributes: { CODECS: 'video-codec' }, contentProtection: { keySystem1: { pssh: 'test' } } }; }, // mocked for renditions mixin main: { playlists: [] } }; this.player.tech_.vhs.playlistController_.mediaTypes_ = { SUBTITLES: {}, AUDIO: { activePlaylistLoader: { media: () => { return { attributes: { CODECS: 'audio-codec' } }; } } } }; this.player.tech_.vhs.playlistController_.sourceUpdater_.trigger('ready'); assert.deepEqual(this.player.currentSource(), { src: 'manifest/main.mpd', type: 'application/dash+xml' }, 'does not set source eme options'); } ); QUnit.test( 'stores bandwidth and throughput in localStorage when global option is true', function(assert) { videojs.options.vhs = { useBandwidthFromLocalStorage: true }; this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); assert.notOk(window.localStorage.getItem(LOCAL_STORAGE_KEY), 'nothing in local storage'); this.player.tech_.vhs.playlistController_.mainSegmentLoader_.bandwidth = 11; this.player.tech_.vhs.playlistController_.mainSegmentLoader_.throughput.rate = 22; this.player.tech_.trigger('bandwidthupdate'); const storedObject = JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY)); assert.equal(parseInt(storedObject.bandwidth, 10), 11, 'set bandwidth'); assert.equal(parseInt(storedObject.throughput, 10), 22, 'set throughput'); } ); QUnit.test( 'stores bandwidth and throughput in localStorage when player option is true', function(assert) { this.player.dispose(); this.player = createPlayer({ html5: { vhs: { useBandwidthFromLocalStorage: true } } }); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); assert.notOk(window.localStorage.getItem(LOCAL_STORAGE_KEY), 'nothing in local storage'); this.player.tech_.vhs.playlistController_.mainSegmentLoader_.bandwidth = 11; this.player.tech_.vhs.playlistController_.mainSegmentLoader_.throughput.rate = 22; this.player.tech_.trigger('bandwidthupdate'); const storedObject = JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY)); assert.equal(parseInt(storedObject.bandwidth, 10), 11, 'set bandwidth'); assert.equal(parseInt(storedObject.throughput, 10), 22, 'set throughput'); } ); QUnit.test( 'stores bandwidth and throughput in localStorage when source option is true', function(assert) { this.player.dispose(); this.player = createPlayer(); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl', useBandwidthFromLocalStorage: true }); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); assert.notOk(window.localStorage.getItem(LOCAL_STORAGE_KEY), 'nothing in local storage'); this.player.tech_.vhs.playlistController_.mainSegmentLoader_.bandwidth = 11; this.player.tech_.vhs.playlistController_.mainSegmentLoader_.throughput.rate = 22; this.player.tech_.trigger('bandwidthupdate'); const storedObject = JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY)); assert.equal(parseInt(storedObject.bandwidth, 10), 11, 'set bandwidth'); assert.equal(parseInt(storedObject.throughput, 10), 22, 'set throughput'); } ); QUnit.test( 'source localStorage option takes priority over player option', function(assert) { this.player.dispose(); this.player = createPlayer({ html5: { vhs: { useBandwidthFromLocalStorage: false } } }); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl', useBandwidthFromLocalStorage: true }); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); assert.notOk(window.localStorage.getItem(LOCAL_STORAGE_KEY), 'nothing in local storage'); this.player.tech_.vhs.playlistController_.mainSegmentLoader_.bandwidth = 11; this.player.tech_.vhs.playlistController_.mainSegmentLoader_.throughput.rate = 22; this.player.tech_.trigger('bandwidthupdate'); const storedObject = JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY)); assert.equal(parseInt(storedObject.bandwidth, 10), 11, 'set bandwidth'); assert.equal(parseInt(storedObject.throughput, 10), 22, 'set throughput'); } ); QUnit.test( 'does not store bandwidth and throughput in localStorage by default', function(assert) { this.player.dispose(); this.player = createPlayer(); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); assert.notOk(window.localStorage.getItem(LOCAL_STORAGE_KEY), 'nothing in local storage'); this.player.tech_.vhs.playlistController_.mainSegmentLoader_.bandwidth = 11; this.player.tech_.vhs.playlistController_.mainSegmentLoader_.throughput.rate = 22; this.player.tech_.trigger('bandwidthupdate'); assert.notOk(window.localStorage.getItem(LOCAL_STORAGE_KEY), 'nothing in local storage'); } ); QUnit.test('retrieves bandwidth and throughput from localStorage', function(assert) { window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ bandwidth: 33, throughput: 44 })); let vhsBandwidthUsageEvents = 0; let vhsThroughputUsageEvents = 0; const usageListener = (event) => { if (event.name === 'vhs-bandwidth-from-local-storage') { vhsBandwidthUsageEvents++; } if (event.name === 'vhs-throughput-from-local-storage') { vhsThroughputUsageEvents++; } }; // values must be stored before player is created, otherwise defaults are provided this.player.dispose(); this.player = createPlayer(); this.player.tech_.on('usage', usageListener); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); assert.equal( this.player.tech_.vhs.bandwidth, 4194304, 'uses default bandwidth when no option to use stored bandwidth' ); assert.notOk( this.player.tech_.vhs.throughput, 'no throughput when no option to use stored throughput' ); assert.equal(vhsBandwidthUsageEvents, 0, 'no bandwidth usage event'); assert.equal(vhsThroughputUsageEvents, 0, 'no throughput usage event'); const origVhsOptions = videojs.options.vhs; videojs.options.vhs = { useBandwidthFromLocalStorage: true }; this.player.dispose(); this.player = createPlayer(); this.player.tech_.on('usage', usageListener); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); assert.equal(this.player.tech_.vhs.bandwidth, 33, 'retrieved stored bandwidth'); assert.equal(this.player.tech_.vhs.throughput, 44, 'retrieved stored throughput'); assert.equal(vhsBandwidthUsageEvents, 1, 'one bandwidth usage event'); assert.equal(vhsThroughputUsageEvents, 1, 'one throughput usage event'); videojs.options.vhs = origVhsOptions; }); QUnit.test( 'does not retrieve bandwidth and throughput from localStorage when stored value is not as expected', function(assert) { // bad value window.localStorage.setItem(LOCAL_STORAGE_KEY, 'a'); let vhsBandwidthUsageEvents = 0; let vhsThroughputUsageEvents = 0; const usageListener = (event) => { if (event.name === 'vhs-bandwidth-from-local-storage') { vhsBandwidthUsageEvents++; } if (event.name === 'vhs-throughput-from-local-storage') { vhsThroughputUsageEvents++; } }; const origVhsOptions = videojs.options.vhs; videojs.options.vhs = { useBandwidthFromLocalStorage: true }; // values must be stored before player is created, otherwise defaults are provided this.player.dispose(); this.player = createPlayer(); this.player.tech_.on('usage', usageListener); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); openMediaSource(this.player, this.clock); assert.equal( this.player.tech_.vhs.bandwidth, 4194304, 'uses default bandwidth when bandwidth value retrieved' ); assert.notOk(this.player.tech_.vhs.throughput, 'no throughput value retrieved'); assert.equal(vhsBandwidthUsageEvents, 0, 'no bandwidth usage event'); assert.equal(vhsThroughputUsageEvents, 0, 'no throughput usage event'); videojs.options.vhs = origVhsOptions; } ); QUnit.test( 'convertToProgramTime will return error if time is not buffered', function(assert) { const done = assert.async(); this.player.src({ src: 'manifest/playlist.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // media this.standardXHRResponse(this.requests.shift()); this.player.tech(true).vhs.convertToProgramTime(3, (err, programTime) => { assert.deepEqual( err, { message: 'Accurate programTime could not be determined.' + ' Please seek to e.seekTime and try again', seekTime: 0 }, 'error is returned as time is not buffered' ); done(); }); } ); QUnit.test('convertToProgramTime will return stream time if buffered', function(assert) { const done = assert.async(); this.player.src({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); this.player.tech_.vhs.bandwidth = 20e10; // main this.standardXHRResponse(this.requests[0]); // media.m3u8 this.standardXHRResponse(this.requests[1]); const pc = this.player.tech(true).vhs.playlistController_; const mainSegmentLoader_ = pc.mainSegmentLoader_; mainSegmentLoader_.one('appending', () => { // since we don't run through the transmuxer, we have to manually trigger the timing // info callback mainSegmentLoader_.handleSegmentTimingInfo_('video', mainSegmentLoader_.pendingSegment_.requestId, { prependedGopDuration: 0, start: { presentation: 0 }, end: { presentation: 1 } }); }); return requestAndAppendSegment({ request: this.requests[2], mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock }).then(() => { // ts this.standardXHRResponse(this.requests[3], muxedSegment()); this.player.tech(true).vhs.convertToProgramTime(0.01, (err, programTime) => { assert.notOk(err, 'no errors'); assert.equal( programTime.mediaSeconds, 0.01, 'returned the stream time of the source' ); done(); }); }); }); QUnit.test( 'seekToProgramTime will error if live stream has not started', function(assert) { this.player.src({ src: 'manifest/program-date-time.m3u8', type: 'application/x-mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // media this.standardXHRResponse(this.requests.shift()); this.player.tech(true).vhs.seekToProgramTime( '2018-10-12T22:33:49.037+00:00', (err, newTime) => { assert.equal( err.message, 'player must be playing a live stream to start buffering', 'error is returned when live stream has not started' ); } ); // allows ie to start loading segments, from setupFirstPlay this.player.tech_.readyState = () => 4; this.player.play(); // trigger playing with non-existent content this.player.tech_.trigger('playing'); // wait for playlist refresh this.clock.tick(4 * 1000 + 1); // ts this.standardXHRResponse(this.requests.shift(), muxedSegment()); this.player.tech(true).vhs.seekToProgramTime( '2018-10-12T22:33:49.037+00:00', (err, newTime) => { assert.equal( err.message, '2018-10-12T22:33:49.037+00:00 is not buffered yet. Try again', 'error returned if time has not been buffered' ); } ); } ); QUnit.test('seekToProgramTime seek to time if buffered', function(assert) { const done = assert.async(); this.player.src({ src: 'manifest/program-date-time.m3u8', type: 'application/x-mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // media this.standardXHRResponse(this.requests.shift()); // allows ie to start loading segments, from setupFirstPlay this.player.tech_.readyState = () => 4; this.player.play(); // trigger playing with non-existent content this.player.tech_.trigger('playing'); // wait for playlist refresh this.clock.tick(2 * 1000 + 1); const pc = this.player.tech(true).vhs.playlistController_; pc.mainSegmentLoader_.one('appending', () => { const videoBuffer = pc.sourceUpdater_.videoBuffer; // must fake the call to videoTimingInfo as the segment isn't transmuxed in the test videoBuffer.trigger({ type: 'videoSegmentTimingInfo', videoSegmentTimingInfo: { start: { presentation: 0 }, end: { presentation: 0.3333 }, baseMediaDecodeTime: 0, prependedContentDuration: 0 } }); }); return requestAndAppendSegment({ request: this.requests.shift(), mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock }).then(() => { this.player.tech(true).vhs.seekToProgramTime( '2018-10-12T22:33:49.037+00:00', (err, newTime) => { assert.notOk( err, 'no error returned' ); assert.equal( newTime, 0, 'newTime is returned as the time the player seeked to' ); done(); } ); // This allows seek to take affect this.clock.tick(2); }); }); QUnit.test('manifest object used as source if provided as data URI', function(assert) { this.player.src({ src: 'placeholder-source', type: 'application/x-mpegurl' }); this.clock.tick(1); openMediaSource(this.player, this.clock); // asynchronous setup of initial playlist in playlist loader for JSON sources this.clock.tick(1); // no manifestObject was provided, so a request is made for the source manifest assert.equal(this.requests.length, 1, 'one request'); assert.equal(this.requests[0].url, 'placeholder-source', 'requested src url'); this.requests.length = 0; const manifestString = testDataManifests.playlist; const manifestObject = parseManifest({ manifestString }); this.player.src({ src: `data:application/vnd.videojs.vhs+json,${JSON.stringify(manifestObject)}`, type: 'application/vnd.videojs.vhs+json' }); openMediaSource(this.player, this.clock); // asynchronous setup of initial playlist in playlist loader for JSON sources this.clock.tick(1); // manifestObject was provided, so a request is made for the segment assert.equal(this.requests.length, 1, 'one request'); assert.equal( this.requests[0].uri, `${window.location.origin}/test/hls_450k_video.ts`, 'requested first segment' ); }); QUnit.module('HLS Integration', { beforeEach(assert) { this.env = useFakeEnvironment(assert); this.requests = this.env.requests; this.mse = useFakeMediaSource(); this.player = createPlayer(); this.tech = this.player.tech_; this.clock = this.env.clock; this.standardXHRResponse = (request, data) => { standardXHRResponse(request, data); // Because SegmentLoader#fillBuffer_ is now scheduled asynchronously // we have to use clock.tick to get the expected side effects of // SegmentLoader#handleAppendsDone_ this.clock.tick(1); }; videojs.VhsHandler.prototype.setupQualityLevels_ = () => {}; }, afterEach() { this.env.restore(); this.mse.restore(); window.localStorage.clear(); this.player.dispose(); videojs.VhsHandler.prototype.setupQualityLevels_ = ogVhsHandlerSetupQualityLevels; } }); QUnit.test('aborts all in-flight work when disposed', function(assert) { const vhs = VhsSourceHandler.handleSource({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); vhs.mediaSource.trigger('sourceopen'); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); vhs.dispose(); assert.ok(this.requests[0].aborted, 'aborted the old segment request'); vhs.mediaSource.sourceBuffers.forEach(sourceBuffer => { const lastUpdate = sourceBuffer.updates_[sourceBuffer.updates_.length - 1]; assert.ok(lastUpdate.abort, 'aborted the source buffer'); }); }); QUnit.test('stats are reset on dispose', function(assert) { const done = assert.async(); const vhs = VhsSourceHandler.handleSource({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); vhs.mediaSource.trigger('sourceopen'); // main this.standardXHRResponse(this.requests.shift()); // media this.standardXHRResponse(this.requests.shift()); const segment = muxedSegment(); // copy the byte length since the segment bytes get cleared out const segmentByteLength = segment.byteLength; assert.ok(segmentByteLength, 'the segment has some number of bytes'); vhs.playlistController_.mainSegmentLoader_.on('appending', () => { assert.equal(vhs.stats.mediaBytesTransferred, segmentByteLength, 'stat is set'); vhs.dispose(); assert.equal(vhs.stats.mediaBytesTransferred, 0, 'stat is reset'); done(); }); // segment 0 this.standardXHRResponse(this.requests.shift(), segment); }); // mocking the fullscreenElement no longer works, find another way to mock // fullscreen behavior(without user gesture) QUnit.skip('detects fullscreen and triggers a fast quality change', function(assert) { const vhs = VhsSourceHandler.handleSource({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); let qualityChanges = 0; let fullscreenElementName; ['fullscreenElement', 'webkitFullscreenElement', 'mozFullScreenElement', 'msFullscreenElement'].forEach((name) => { if (!fullscreenElementName && !document.hasOwnProperty(name)) { fullscreenElementName = name; } }); vhs.playlistController_.fastQualityChange_ = function() { qualityChanges++; }; // take advantage of capability detection to mock fullscreen activation document[fullscreenElementName] = this.tech.el(); Events.trigger(document, 'fullscreenchange'); assert.equal(qualityChanges, 1, 'made a fast quality change'); let checkABRCalls = 0; vhs.playlistController_.checkABR_ = () => checkABRCalls++; // don't do a fast quality change when returning from fullscreen; // // do check the current rendition to see if it should be changed for the next // segment loaded document[fullscreenElementName] = null; Events.trigger(document, 'fullscreenchange'); assert.equal(qualityChanges, 1, 'did not make another quality change'); assert.equal(checkABRCalls, 1, 'called to check the ABR'); vhs.dispose(); }); QUnit.test('downloads additional playlists if required', function(assert) { const vhs = VhsSourceHandler.handleSource({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); // Make segment metadata noop since most test segments dont have real data vhs.playlistController_.mainSegmentLoader_.addSegmentMetadataCue_ = () => {}; vhs.mediaSource.trigger('sourceopen'); vhs.bandwidth = 1; // main this.standardXHRResponse(this.requests[0]); // media this.standardXHRResponse(this.requests[1]); const originalPlaylist = vhs.playlists.media(); const pc = vhs.playlistController_; pc.mainSegmentLoader_.mediaIndex = 0; return requestAndAppendSegment({ request: this.requests[2], mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock, // the playlist selection is revisited after a new segment is downloaded bandwidth: 3000000, tickClock: false }).then(() => { // update the buffer to reflect the appended segment, and have enough buffer to // change playlist this.tech.buffered = () => createTimeRanges([[0, 30]]); this.clock.tick(1); // new media this.standardXHRResponse(this.requests[3]); assert.ok( (/manifest\/media\d+.m3u8$/).test(this.requests[3].url), 'made a playlist request' ); assert.notEqual( originalPlaylist.resolvedUri, vhs.playlists.media().resolvedUri, 'a new playlists was selected' ); assert.ok(vhs.playlists.media().segments, 'segments are now available'); vhs.dispose(); }); }); QUnit.test('waits to download new segments until the media playlist is stable', function(assert) { const vhs = VhsSourceHandler.handleSource({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); const pc = vhs.playlistController_; pc.mainSegmentLoader_.addSegmentMetadataCue_ = () => {}; vhs.mediaSource.trigger('sourceopen'); // make sure we stay on the lowest variant vhs.bandwidth = 1; // main this.standardXHRResponse(this.requests.shift()); // media1 this.standardXHRResponse(this.requests.shift()); // put segment loader in walking forward mode pc.mainSegmentLoader_.mediaIndex = 0; return requestAndAppendSegment({ request: this.requests.shift(), mediaSource: pc.mediaSource, segmentLoader: pc.mainSegmentLoader_, clock: this.clock, // bandwidth is high enough to switch playlists bandwidth: Number.MAX_VALUE, tickClock: false }).then(() => { // update the buffer to reflect the appended segment, and have enough buffer to // change playlist this.tech.buffered = () => createTimeRanges([[0, 30]]); this.clock.tick(1); assert.equal(this.requests.length, 1, 'only the playlist request outstanding'); this.clock.tick(10 * 1000); assert.equal(this.requests.length, 1, 'delays segment fetching'); // another media playlist this.standardXHRResponse(this.requests.shift()); this.clock.tick(10 * 1000); assert.equal(this.requests.length, 1, 'resumes segment fetching'); // verify stats assert.equal(vhs.stats.bandwidth, Infinity, 'bandwidth is set to infinity'); vhs.dispose(); }); }); QUnit.test('live playlist starts three target durations before live', function(assert) { const vhs = VhsSourceHandler.handleSource({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); vhs.mediaSource.trigger('sourceopen'); this.requests.shift().respond( 200, null, '#EXTM3U\n' + '#EXT-X-MEDIA-SEQUENCE:101\n' + '#EXT-X-TARGETDURATION:10\n' + '#EXTINF:10,\n' + '0.ts\n' + '#EXTINF:10,\n' + '1.ts\n' + '#EXTINF:10,\n' + '2.ts\n' + '#EXTINF:10,\n' + '3.ts\n' + '#EXTINF:10,\n' + '4.ts\n' ); assert.equal(this.requests.length, 0, 'no outstanding segment request'); this.tech.paused = function() { return false; }; let techCurrentTime = 0; this.tech.setCurrentTime = function(ct) { techCurrentTime = ct; }; this.tech.readyState = () => 4; this.tech.trigger('play'); this.clock.tick(1); assert.equal( vhs.seekable().end(0), 20, 'seekable end is three target durations from playlist end' ); assert.equal( techCurrentTime, vhs.seekable().end(0), 'seeked to the seekable end' ); assert.equal(this.requests.length, 1, 'begins buffering'); vhs.dispose(); }); QUnit.test( 'uses user defined selectPlaylist from VhsHandler if specified', function(assert) { const origStandardPlaylistSelector = Vhs.STANDARD_PLAYLIST_SELECTOR; let defaultSelectPlaylistCount = 0; Vhs.STANDARD_PLAYLIST_SELECTOR = () => defaultSelectPlaylistCount++; let vhs = VhsSourceHandler.handleSource({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); vhs.playlistController_.selectPlaylist(); assert.equal(defaultSelectPlaylistCount, 1, 'uses default playlist selector'); defaultSelectPlaylistCount = 0; let newSelectPlaylistCount = 0; const newSelectPlaylist = () => newSelectPlaylistCount++; VhsHandler.prototype.selectPlaylist = newSelectPlaylist; vhs.dispose(); vhs = VhsSourceHandler.handleSource({ src: 'manifest/main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); vhs.playlistController_.selectPlaylist(); assert.equal(defaultSelectPlaylistCount, 0, 'standard playlist selector not run'); assert.equal(newSelectPlaylistCount, 1, 'uses overridden playlist selector'); newSelectPlaylistCount = 0; let setSelectPlaylistCount = 0; vhs.selectPlaylist = () => setSelectPlaylistCount++; vhs.playlistController_.selectPlaylist(); assert.equal(defaultSelectPlaylistCount, 0, 'standard playlist selector not run'); assert.equal(newSelectPlaylistCount, 0, 'overridden playlist selector not run'); assert.equal(setSelectPlaylistCount, 1, 'uses set playlist selector'); Vhs.STANDARD_PLAYLIST_SELECTOR = origStandardPlaylistSelector; delete VhsHandler.prototype.selectPlaylist; vhs.dispose(); } ); QUnit.module('HLS - Encryption', { beforeEach(assert) { this.env = useFakeEnvironment(assert); this.requests = this.env.requests; this.mse = useFakeMediaSource(); this.tech = new (videojs.getTech('Html5'))({}); this.clock = this.env.clock; this.standardXHRResponse = (request, data) => { standardXHRResponse(request, data); // Because SegmentLoader#fillBuffer_ is now scheduled asynchronously // we have to use clock.tick to get the expected side effects of // SegmentLoader#handleAppendsDone_ this.clock.tick(1); }; videojs.VhsHandler.prototype.setupQualityLevels_ = () => {}; }, afterEach() { this.env.restore(); this.mse.restore(); window.localStorage.clear(); videojs.VhsHandler.prototype.setupQualityLevels_ = ogVhsHandlerSetupQualityLevels; } }); QUnit.test('excludes playlist if key requests fail', function(assert) { const vhs = VhsSourceHandler.handleSource({ src: 'manifest/encrypted-main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); vhs.mediaSource.trigger('sourceopen'); this.requests.shift() .respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1000\n' + 'media.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + 'media1.m3u8\n' ); this.requests.shift() .respond( 200, null, '#EXTM3U\n' + '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=52"\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence52-A.ts\n' + '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=53"\n' + '#EXTINF:15.0,\n' + 'http://media.example.com/fileSequence53-A.ts\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); // segment 1 if (/key\.php/i.test(this.requests[0].url)) { this.standardXHRResponse(this.requests.pop()); } else { this.standardXHRResponse(this.requests.shift()); } // fail key this.requests.shift().respond(404); assert.ok( vhs.playlists.media().excludeUntil > 0, 'playlist excluded' ); assert.equal(this.env.log.warn.calls, 1, 'logged warning for exclusion'); vhs.dispose(); }); QUnit.test( 'treats invalid keys as a key request failure and excludes playlist', function(assert) { const vhs = VhsSourceHandler.handleSource({ src: 'manifest/encrypted-main.m3u8', type: 'application/vnd.apple.mpegurl' }, this.tech); vhs.mediaSource.trigger('sourceopen'); this.requests.shift() .respond( 200, null, '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1000\n' + 'media.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + 'media1.m3u8\n' ); this.requests.shift() .respond( 200, null, '#EXTM3U\n' + '#EXT-X-MEDIA-SEQUENCE:5\n' + '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n' + '#EXTINF:2.833,\n' + 'http://media.example.com/fileSequence52-A.ts\n' + '#EXT-X-KEY:METHOD=NONE\n' + '#EXTINF:15.0,\n' + 'http://media.example.com/fileSequence52-B.ts\n' + '#EXT-X-ENDLIST\n' ); this.clock.tick(1); // segment request this.standardXHRResponse(this.requests.pop()); assert.equal( this.requests[0].url, 'https://priv.example.com/key.php?r=52', 'requested the key' ); // keys *should* be 16 bytes long -- this one is too small this.requests[0].response = new Uint8Array(1).buffer; this.requests.shift().respond(200, null, ''); this.clock.tick(1); // exclude this playlist assert.ok( vhs.playlists.media().excludeUntil > 0, 'exclude playlist' ); assert.equal(this.env.log.warn.calls, 1, 'logged warning for exclusion'); // verify stats assert.equal(vhs.stats.mediaBytesTransferred, 1024, '1024 bytes'); assert.equal(vhs.stats.mediaRequests, 1, '1 request'); vhs.dispose(); } ); QUnit.module('videojs-http-streaming isolated functions'); QUnit.test('emeKeySystems adds content types for all keySystems', function(assert) { // muxed content assert.deepEqual( emeKeySystems( { keySystem1: {}, keySystem2: {} }, { attributes: { CODECS: 'avc1.420015, mp4a.40.2c' } } ), { keySystem1: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"' }, keySystem2: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"' } }, 'added content types' ); // unmuxed content assert.deepEqual( emeKeySystems( { keySystem1: {}, keySystem2: {} }, { attributes: { CODECS: 'avc1.420015' } }, { attributes: { CODECS: 'mp4a.40.2c' } } ), { keySystem1: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"' }, keySystem2: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"' } }, 'added content types' ); }); QUnit.test('emeKeySystems supports audio only', function(assert) { assert.deepEqual( emeKeySystems( { keySystem1: {}, keySystem2: {} }, { attributes: { CODECS: 'mp4a.40.2c' } } ), { keySystem1: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"' }, keySystem2: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"' } }, 'added content type' ); }); QUnit.test('emeKeySystems supports external audio only', function(assert) { assert.deepEqual( emeKeySystems( { keySystem1: {}, keySystem2: {} }, { attributes: {} }, { attributes: { CODECS: 'mp4a.40.2c' } } ), { keySystem1: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"' }, keySystem2: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"' } }, 'added content type' ); }); QUnit.test('emeKeySystems supports video only', function(assert) { assert.deepEqual( emeKeySystems( { keySystem1: {}, keySystem2: {} }, { attributes: { CODECS: 'avc1.420015' } } ), { keySystem1: { videoContentType: 'video/mp4;codecs="avc1.420015"' }, keySystem2: { videoContentType: 'video/mp4;codecs="avc1.420015"' } }, 'added content type' ); }); QUnit.test('emeKeySystems retains non content type properties', function(assert) { assert.deepEqual( emeKeySystems( { keySystem1: { url: '1' }, keySystem2: { url: '2'} }, { attributes: { CODECS: 'avc1.420015, mp4a.40.2c' } } ), { keySystem1: { url: '1', audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"' }, keySystem2: { url: '2', audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"' } }, 'retained options' ); }); QUnit.test('emeKeySystems overwrites content types', function(assert) { assert.deepEqual( emeKeySystems( { keySystem1: { audioContentType: 'a', videoContentType: 'b' }, keySystem2: { audioContentType: 'c', videoContentType: 'd' } }, { attributes: { CODECS: 'avc1.420015, mp4a.40.2c' } } ), { keySystem1: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"' }, keySystem2: { audioContentType: 'audio/mp4;codecs="mp4a.40.2c"', videoContentType: 'video/mp4;codecs="avc1.420015"' } }, 'overwrote content types' ); }); QUnit.test('expandDataUri parses JSON for VHS media type', function(assert) { const manifestObject = { test: 'manifest', object: ['here'] }; const xMpegDataUriString = `data:application/x-mpegURL,${JSON.stringify(manifestObject)}`; assert.deepEqual( expandDataUri(xMpegDataUriString), xMpegDataUriString, 'does not parse JSON for non VHS media type' ); assert.deepEqual( expandDataUri(`data:application/vnd.videojs.vhs+json,${JSON.stringify(manifestObject)}`), manifestObject, 'parsed JSON from data URI for VHS media type' ); }); QUnit.test('expandDataUri is case insensitive', function(assert) { const manifestObject = { test: 'manifest', object: ['here'] }; assert.deepEqual( expandDataUri(`DaTa:ApPlIcAtIoN/VnD.ViDeOjS.VhS+JsOn,${JSON.stringify(manifestObject)}`), manifestObject, 'parsed JSON from data URI for VHS media type' ); }); QUnit.test('expandDataUri requires comma to parse', function(assert) { assert.deepEqual( expandDataUri('data:application/vnd.videojs.vhs+json'), 'data:application/vnd.videojs.vhs+json', 'did not parse when no comma after data URI' ); }); QUnit.module('setupEmeOptions'); QUnit.test('no error if no eme and no key systems', function(assert) { const player = {}; const sourceKeySystems = null; const media = {}; const audioMedia = {}; assert.notOk( setupEmeOptions({ player, sourceKeySystems, media, audioMedia }), 'did not configure EME options' ); assert.ok(true, 'no exception'); }); QUnit.test('log error if no eme and we have key systems', function(assert) { const sourceKeySystems = {}; const media = {}; const audioMedia = {}; const src = {}; const player = {currentSource: () => src}; let logWarn; const origWarn = videojs.log.warn; videojs.log.warn = (line) => { logWarn = line; }; assert.notOk( setupEmeOptions({ player, sourceKeySystems, media, audioMedia }), 'did not configure EME options' ); assert.equal(logWarn, 'DRM encrypted source cannot be decrypted without a DRM plugin', 'logs expected error'); assert.ok(src.hasOwnProperty('keySystems'), 'source key systems was set'); videojs.log.warn = origWarn; }); QUnit.test('converts options for muxed playlist', function(assert) { const currentSource = {}; const player = { eme: {}, currentSource: () => currentSource }; const sourceKeySystems = { 'com.widevine.alpha': { url: 'license-url' } }; const media = { attributes: { CODECS: 'avc1.4d400d,mp4a.40.2' }, contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array() } } }; const audioMedia = null; assert.ok( setupEmeOptions({ player, sourceKeySystems, media, audioMedia }), 'configured EME options' ); assert.deepEqual( currentSource.keySystems, { 'com.widevine.alpha': { audioContentType: 'audio/mp4;codecs="mp4a.40.2"', videoContentType: 'video/mp4;codecs="avc1.4d400d"', pssh: new Uint8Array(), url: 'license-url' } }, 'eme keySystems options are corect' ); }); QUnit.test('converts options for demuxed playlists', function(assert) { const currentSource = {}; const player = { eme: {}, currentSource: () => currentSource }; const sourceKeySystems = { 'com.widevine.alpha': { url: 'license-url' } }; const media = { attributes: { CODECS: 'avc1.4d400d,mp4a.40.2' }, contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array([1, 2, 3]) } } }; const audioMedia = { attributes: {}, contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array([4, 5, 6]) } } }; assert.ok( setupEmeOptions({ player, sourceKeySystems, media, audioMedia }), 'configured eme options' ); assert.deepEqual( currentSource.keySystems, { 'com.widevine.alpha': { audioContentType: 'audio/mp4;codecs="mp4a.40.2"', videoContentType: 'video/mp4;codecs="avc1.4d400d"', pssh: new Uint8Array([1, 2, 3]), url: 'license-url' } }, 'eme keySystems options are correct' ); }); QUnit.module('getAllPsshKeySystemsOptions'); QUnit.test('empty array if no content protection in playlists', function(assert) { assert.deepEqual( getAllPsshKeySystemsOptions( [{}, {}], ['com.widevine.alpha', 'com.microsoft.playready'] ), [], 'returned an empty array' ); }); QUnit.test('empty array if no matching key systems in playlists', function(assert) { assert.deepEqual( getAllPsshKeySystemsOptions( [{ contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array() } } }, { contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array() } } }], ['com.microsoft.playready'] ), [], 'returned an empty array' ); }); QUnit.test('empty array if no pssh in playlist contentProtection', function(assert) { assert.deepEqual( getAllPsshKeySystemsOptions( [{ contentProtection: { 'com.widevine.alpha': {}, 'com.microsoft.playready': {} } }, { contentProtection: { 'com.widevine.alpha': {}, 'com.microsoft.playready': {} } }], ['com.widevine.alpha', 'com.microsoft.playready'] ), [], 'returned an empty array' ); }); QUnit.test('returns all key systems and pssh values', function(assert) { assert.deepEqual( getAllPsshKeySystemsOptions( [{ contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array([0]), otherProperty: true }, 'com.microsoft.playready': { pssh: new Uint8Array([1]), otherProperty: true } } }, { contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array([2]), otherProperty: true }, 'com.microsoft.playready': { pssh: new Uint8Array([3]), otherProperty: true } } }], ['com.widevine.alpha', 'com.microsoft.playready'] ), [{ 'com.widevine.alpha': { pssh: new Uint8Array([0]) }, 'com.microsoft.playready': { pssh: new Uint8Array([1]) } }, { 'com.widevine.alpha': { pssh: new Uint8Array([2]) }, 'com.microsoft.playready': { pssh: new Uint8Array([3]) } }], 'returned key systems and pssh values without other properties' ); }); QUnit.module('waitForKeySessionCreation', { beforeEach(assert) { const listeners = []; this.player = { eme: { initializeMediaKeys(options, callback) { callback(); } }, tech_: { one(eventName, callback) { listeners.push({ eventName, callback }); }, listeners } }; this.completeOptions = { player: this.player, sourceKeySystems: { 'com.widevine.alpha': { url: 'license-url' } }, audioMedia: null, mainPlaylists: [{ contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array() } } }] }; } }); QUnit.test('resolves on initializeMediaKeys', function(assert) { const done = assert.async(); return waitForKeySessionCreation(this.completeOptions).then(() => { assert.ok(true, 'resolved promise'); done(); }); }); QUnit.test('resolves on all initializeMediaKeys', function(assert) { const done = assert.async(); const initializeCalls = []; const initializeCallbacks = []; this.completeOptions.mainPlaylists = [{ contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array([0, 0, 0]) } } }, { contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array([1, 2, 3]) } } }]; this.player.eme.initializeMediaKeys = (options, callback) => { initializeCalls.push(options); initializeCallbacks.push(callback); }; waitForKeySessionCreation(this.completeOptions).then(() => { assert.deepEqual( initializeCalls, [ { keySystems: { 'com.widevine.alpha': { pssh: new Uint8Array([0, 0, 0]) } } }, { keySystems: { 'com.widevine.alpha': { pssh: new Uint8Array([1, 2, 3]) } } } ], 'waited for both initialize calls to resolve' ); done(); }); assert.equal(initializeCallbacks.length, 2, 'two initialize calls'); initializeCallbacks[0](); setTimeout(() => { // call the second callback async to ensure the promise waits for all initializeCallbacks[1](); }, 1); }); QUnit.test('resolves on all initializeMediaKeys when demuxed', function(assert) { const done = assert.async(); const initializeCalls = []; const initializeCallbacks = []; this.completeOptions.mainPlaylists = [{ contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array([0, 0, 0]) } } }, { contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array([1, 2, 3]) } } }]; this.completeOptions.audioMedia = { contentProtection: { 'com.widevine.alpha': { pssh: new Uint8Array([4, 5, 6]) } } }; this.player.eme.initializeMediaKeys = (options, callback) => { initializeCalls.push(options); initializeCallbacks.push(callback); }; waitForKeySessionCreation(this.completeOptions).then(() => { assert.deepEqual( initializeCalls, [ { keySystems: { 'com.widevine.alpha': { pssh: new Uint8Array([0, 0, 0]) } } }, { keySystems: { 'com.widevine.alpha': { pssh: new Uint8Array([1, 2, 3]) } } }, { keySystems: { 'com.widevine.alpha': { pssh: new Uint8Array([4, 5, 6]) } } } ], 'waited for all video and audio initialize calls to resolve' ); done(); }); assert.equal(initializeCallbacks.length, 3, 'three initialize calls'); initializeCallbacks[0](); setTimeout(() => { // call the second callback async to ensure the promise waits for all initializeCallbacks[1](); setTimeout(() => { // call the third callback async to ensure the promise waits for all initializeCallbacks[2](); }, 1); }, 1); }); QUnit.test('resolves on keysessioncreated', function(assert) { const done = assert.async(); // never allow initializeMediaKeys to finish this.player.eme.initializeMediaKeys = (options, callback) => {}; waitForKeySessionCreation(this.completeOptions).then(() => { done(); }); assert.equal(this.player.tech_.listeners.length, 1, 'one listener'); assert.equal(this.player.tech_.listeners[0].eventName, 'keysessioncreated'); this.player.tech_.listeners[0].callback(); }); QUnit.test('resolves if no initializeMediaKeys', function(assert) { const done = assert.async(); delete this.player.eme.initializeMediaKeys; return waitForKeySessionCreation(this.completeOptions).then(() => { assert.ok(true, 'resolved promise'); done(); }); });