diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index df4377a2..c4738e26 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -820,14 +820,14 @@ export class MasterPlaylistController extends videojs.EventTarget { if (!buffered.length) { // return true if the playhead reached the absolute end of the playlist - return absolutePlaylistEnd - currentTime <= Ranges.TIME_FUDGE_FACTOR; + return absolutePlaylistEnd - currentTime <= Ranges.SAFE_TIME_DELTA; } let bufferedEnd = buffered.end(buffered.length - 1); - // return true if there is too little buffer left and - // buffer has reached absolute end of playlist - return bufferedEnd - currentTime <= Ranges.TIME_FUDGE_FACTOR && - absolutePlaylistEnd - bufferedEnd <= Ranges.TIME_FUDGE_FACTOR; + // return true if there is too little buffer left and buffer has reached absolute + // end of playlist + return bufferedEnd - currentTime <= Ranges.SAFE_TIME_DELTA && + absolutePlaylistEnd - bufferedEnd <= Ranges.SAFE_TIME_DELTA; } /** diff --git a/src/playback-watcher.js b/src/playback-watcher.js index 9314b924..4ff2f1e6 100644 --- a/src/playback-watcher.js +++ b/src/playback-watcher.js @@ -107,7 +107,8 @@ export default class PlaybackWatcher { let buffered = this.tech_.buffered(); if (this.lastRecordedTime === currentTime && - (!buffered.length || currentTime + 0.1 >= buffered.end(buffered.length - 1))) { + (!buffered.length || + currentTime + Ranges.SAFE_TIME_DELTA >= buffered.end(buffered.length - 1))) { // If current time is at the end of the final buffered region, then any playback // stall is most likely caused by buffering in a low bandwidth environment. The tech // should fire a `waiting` event in this scenario, but due to browser and tech @@ -171,7 +172,7 @@ export default class PlaybackWatcher { // sync to the beginning of the live window // provide a buffer of .1 seconds to handle rounding/imprecise numbers - seekTo = seekableStart + 0.1; + seekTo = seekableStart + Ranges.SAFE_TIME_DELTA; } if (typeof seekTo !== 'undefined') { @@ -298,8 +299,7 @@ export default class PlaybackWatcher { return false; } - // provide a buffer of .1 seconds to handle rounding/imprecise numbers - if (currentTime > seekable.end(seekable.length - 1) + 0.1) { + if (currentTime > seekable.end(seekable.length - 1) + Ranges.SAFE_TIME_DELTA) { return true; } @@ -310,8 +310,7 @@ export default class PlaybackWatcher { if (seekable.length && // can't fall before 0 and 0 seekable start identifies VOD stream seekable.start(0) > 0 && - // provide a buffer of .1 seconds to handle rounding/imprecise numbers - currentTime < seekable.start(0) - 0.1) { + currentTime < seekable.start(0) - Ranges.SAFE_TIME_DELTA) { return true; } diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 8424e566..fb4ce347 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -141,6 +141,30 @@ export const resolveMediaGroupUris = (master) => { }); }; +/** + * Calculates the time to wait before refreshing a live playlist + * + * @param {Object} media + * The current media + * @param {Boolean} update + * True if there were any updates from the last refresh, false otherwise + * @return {Number} + * The time in ms to wait before refreshing the live playlist + */ +export const refreshDelay = (media, update) => { + const lastSegment = media.segments[media.segments.length - 1]; + let delay; + + if (update && lastSegment && lastSegment.duration) { + delay = lastSegment.duration * 1000; + } else { + // if the playlist is unchanged since the last reload or last segment duration + // cannot be determined, try again after half the target duration + delay = (media.targetDuration || 10) * 500; + } + return delay; +}; + /** * Load a playlist from a remote location * @@ -230,16 +254,13 @@ export default class PlaylistLoader extends EventTarget { // merge this playlist into the master const update = updateMaster(this.master, parser.manifest); - let refreshDelay = (parser.manifest.targetDuration || 10) * 1000; this.targetDuration = parser.manifest.targetDuration; + if (update) { this.master = update; this.media_ = this.master.playlists[parser.manifest.uri]; } else { - // if the playlist is unchanged since the last reload, - // try again after half the target duration - refreshDelay /= 2; this.trigger('playlistunchanged'); } @@ -248,7 +269,7 @@ export default class PlaylistLoader extends EventTarget { window.clearTimeout(this.mediaUpdateTimeout); this.mediaUpdateTimeout = window.setTimeout(() => { this.trigger('mediaupdatetimeout'); - }, refreshDelay); + }, refreshDelay(this.media(), !!update)); } this.trigger('loadedplaylist'); @@ -450,9 +471,9 @@ export default class PlaylistLoader extends EventTarget { const media = this.media(); if (isFinalRendition) { - const refreshDelay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000; + const delay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000; - this.mediaUpdateTimeout = window.setTimeout(() => this.load(), refreshDelay); + this.mediaUpdateTimeout = window.setTimeout(() => this.load(), delay); return; } diff --git a/src/playlist.js b/src/playlist.js index 99a9f020..215204a4 100644 --- a/src/playlist.js +++ b/src/playlist.js @@ -6,16 +6,6 @@ import {createTimeRange} from 'video.js'; import window from 'global/window'; -let Playlist = { - /** - * The number of segments that are unsafe to start playback at in - * a live stream. Changing this value can cause playback stalls. - * See HTTP Live Streaming, "Playing the Media Playlist File" - * https://tools.ietf.org/html/draft-pantos-http-live-streaming-18#section-6.3.3 - */ - UNSAFE_LIVE_SEGMENTS: 3 -}; - /** * walk backward until we find a duration we can use * or return a failure @@ -219,6 +209,38 @@ export const sumDurations = function(playlist, startIndex, endIndex) { return durations; }; +/** + * Determines the media index of the segment corresponding to the safe edge of the live + * window which is the duration of the last segment plus 2 target durations from the end + * of the playlist. + * + * @param {Object} playlist + * a media playlist object + * @return {Number} + * The media index of the segment at the safe live point. 0 if there is no "safe" + * point. + * @function safeLiveIndex + */ +export const safeLiveIndex = function(playlist) { + if (!playlist.segments.length) { + return 0; + } + + let i = playlist.segments.length - 1; + let distanceFromEnd = playlist.segments[i].duration || playlist.targetDuration; + const safeDistance = distanceFromEnd + playlist.targetDuration * 2; + + while (i--) { + distanceFromEnd += playlist.segments[i].duration; + + if (distanceFromEnd >= safeDistance) { + break; + } + } + + return Math.max(0, i); +}; + /** * Calculates the playlist end time * @@ -246,9 +268,7 @@ export const playlistEnd = function(playlist, expired, useSafeLiveEnd) { expired = expired || 0; - let endSequence = useSafeLiveEnd ? - Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS) : - Math.max(0, playlist.segments.length); + const endSequence = useSafeLiveEnd ? safeLiveIndex(playlist) : playlist.segments.length; return intervalDuration(playlist, playlist.mediaSequence + endSequence, @@ -506,18 +526,19 @@ export const estimateSegmentRequestTime = function(segmentDuration, return (size - (bytesReceived * 8)) / bandwidth; }; -Playlist.duration = duration; -Playlist.seekable = seekable; -Playlist.getMediaInfoForTime = getMediaInfoForTime; -Playlist.isEnabled = isEnabled; -Playlist.isDisabled = isDisabled; -Playlist.isBlacklisted = isBlacklisted; -Playlist.isIncompatible = isIncompatible; -Playlist.playlistEnd = playlistEnd; -Playlist.isAes = isAes; -Playlist.isFmp4 = isFmp4; -Playlist.hasAttribute = hasAttribute; -Playlist.estimateSegmentRequestTime = estimateSegmentRequestTime; - // exports -export default Playlist; +export default { + duration, + seekable, + safeLiveIndex, + getMediaInfoForTime, + isEnabled, + isDisabled, + isBlacklisted, + isIncompatible, + playlistEnd, + isAes, + isFmp4, + hasAttribute, + estimateSegmentRequestTime +}; diff --git a/src/ranges.js b/src/ranges.js index 14652829..e2059c23 100644 --- a/src/ranges.js +++ b/src/ranges.js @@ -9,6 +9,12 @@ import videojs from 'video.js'; // Fudge factor to account for TimeRanges rounding const TIME_FUDGE_FACTOR = 1 / 30; +// Comparisons between time values such as current time and the end of the buffered range +// can be misleading because of precision differences or when the current media has poorly +// aligned audio and video, which can cause values to be slightly off from what you would +// expect. This value is what we consider to be safe to use in such comparisons to account +// for these scenarios. +const SAFE_TIME_DELTA = TIME_FUDGE_FACTOR * 3; /** * Clamps a value to within a range @@ -365,6 +371,7 @@ export default { findSoleUncommonTimeRangesEnd, getSegmentBufferedPercent, TIME_FUDGE_FACTOR, + SAFE_TIME_DELTA, printableRange, timeUntilRebuffer }; diff --git a/src/segment-loader.js b/src/segment-loader.js index 39c82b7d..4fc186e0 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -73,6 +73,38 @@ export const illegalMediaSwitch = (loaderType, startingMedia, newSegmentMedia) = return null; }; +/** + * Calculates a time value that is safe to remove from the back buffer without interupting + * playback. + * + * @param {TimeRange} seekable + * The current seekable range + * @param {Number} currentTime + * The current time of the player + * @param {Number} targetDuration + * The target duration of the current playlist + * @return {Number} + * Time that is safe to remove from the back buffer without interupting playback + */ +export const safeBackBufferTrimTime = (seekable, currentTime, targetDuration) => { + let removeToTime; + + if (seekable.length && + seekable.start(0) > 0 && + seekable.start(0) < currentTime) { + // If we have a seekable range use that as the limit for what can be removed safely + removeToTime = seekable.start(0); + } else { + // otherwise remove anything older than 30 seconds before the current play head + removeToTime = currentTime - 30; + } + + // Don't allow removing from the buffer within target duration of current time + // to avoid the possibility of removing the GOP currently being played which could + // cause playback stalls. + return Math.min(removeToTime, currentTime - targetDuration); +}; + /** * An object that manages segment loading and appending. * @@ -906,9 +938,9 @@ export default class SegmentLoader extends videojs.EventTarget { * @param {Object} segmentInfo - the current segment */ trimBackBuffer_(segmentInfo) { - const seekable = this.seekable_(); - const currentTime = this.currentTime_(); - let removeToTime = 0; + const removeToTime = safeBackBufferTrimTime(this.seekable_(), + this.currentTime_(), + this.playlist_.targetDuration || 10); // Chrome has a hard limit of 150MB of // buffer and a very conservative "garbage collector" @@ -916,16 +948,6 @@ export default class SegmentLoader extends videojs.EventTarget { // we don't trigger the QuotaExceeded error // on the source buffer during subsequent appends - // If we have a seekable range use that as the limit for what can be removed safely - // otherwise remove anything older than 30 seconds before the current play head - if (seekable.length && - seekable.start(0) > 0 && - seekable.start(0) < currentTime) { - removeToTime = seekable.start(0); - } else { - removeToTime = currentTime - 30; - } - if (removeToTime > 0) { this.remove(0, removeToTime); } diff --git a/test/playlist-loader.test.js b/test/playlist-loader.test.js index 33445f4d..005e5ac7 100644 --- a/test/playlist-loader.test.js +++ b/test/playlist-loader.test.js @@ -4,7 +4,8 @@ import { updateSegments, updateMaster, setupMediaPlaylists, - resolveMediaGroupUris + resolveMediaGroupUris, + refreshDelay } from '../src/playlist-loader'; import xhrFactory from '../src/xhr'; import { useFakeEnvironment } from './test-helpers'; @@ -773,6 +774,23 @@ QUnit.test('resolveMediaGroupUris resolves media group URIs', function(assert) { }, 'resolved URIs of certain media groups'); }); +QUnit.test('uses last segment duration for refresh delay', function(assert) { + const media = { targetDuration: 7, segments: [] }; + + assert.equal(refreshDelay(media, true), 3500, + 'used half targetDuration when no segments'); + + media.segments = [ { duration: 6}, { duration: 4 }, { } ]; + assert.equal(refreshDelay(media, true), 3500, + 'used half targetDuration when last segment duration cannot be determined'); + + media.segments = [ { duration: 6}, { duration: 4}, { duration: 5 } ]; + assert.equal(refreshDelay(media, true), 5000, 'used last segment duration for delay'); + + assert.equal(refreshDelay(media, false), 3500, + 'used half targetDuration when update is false'); +}); + QUnit.test('throws if the playlist url is empty or undefined', function(assert) { assert.throws(function() { PlaylistLoader(); @@ -1180,6 +1198,27 @@ QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function(as assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); }); +QUnit.test('refreshes the playlist after last segment duration', function(assert) { + let loader = new PlaylistLoader('live.m3u8', this.fakeHls); + let refreshes = 0; + + loader.on('mediaupdatetimeout', () => refreshes++); + + loader.load(); + + this.requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:10\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXTINF:4\n' + + '1.ts\n'); + // 4s, last segment duration + this.clock.tick(4 * 1000); + + assert.equal(refreshes, 1, 'refreshed playlist after last segment duration'); +}); + QUnit.test('emits an error when an initial playlist request fails', function(assert) { let errors = []; let loader = new PlaylistLoader('master.m3u8', this.fakeHls); diff --git a/test/playlist.test.js b/test/playlist.test.js index ba2c3383..41bdf3f8 100644 --- a/test/playlist.test.js +++ b/test/playlist.test.js @@ -356,7 +356,7 @@ function(assert) { QUnit.test('seekable end and playlist end account for non-standard target durations', function(assert) { - let playlist = { + const playlist = { targetDuration: 2, mediaSequence: 0, syncInfo: { @@ -385,11 +385,113 @@ function(assert) { assert.equal(seekable.start(0), 0, 'starts at the earliest available segment'); assert.equal(seekable.end(0), - 9 - (2 + 2 + 1), - 'allows seeking no further than three segments from the end'); + // Playlist duration is 9s. Target duration 2s. Seekable end should be at + // least 6s from end. Adding segment durations starting from the end to get + // that 6s target + 9 - (2 + 2 + 1 + 2), + 'allows seeking no further than the start of the segment 2 target' + + 'durations back from the beginning of the last segment'); assert.equal(playlistEnd, 9, 'playlist end at the last segment'); }); +QUnit.test('safeLiveIndex is correct for standard segment durations', function(assert) { + const playlist = { + targetDuration: 6, + mediaSequence: 10, + syncInfo: { + time: 0, + mediaSequence: 10 + }, + segments: [ + { + duration: 6 + }, + { + duration: 6 + }, + { + duration: 6 + }, + { + duration: 6 + }, + { + duration: 6 + }, + { + duration: 6 + } + ] + }; + + assert.equal(Playlist.safeLiveIndex(playlist), 3, + 'correct media index for standard durations'); +}); + +QUnit.test('safeLiveIndex is correct for variable segment durations', function(assert) { + const playlist = { + targetDuration: 6, + mediaSequence: 10, + syncInfo: { + time: 0, + mediaSequence: 10 + }, + segments: [ + { + duration: 6 + }, + { + duration: 4 + }, + { + duration: 5 + }, + { + // this segment is 16 seconds from the end of playlist, the safe live point + duration: 6 + }, + { + duration: 3 + }, + { + duration: 4 + }, + { + duration: 3 + } + ] + }; + + // safe live point is no less than 15 seconds (3s + 2 * 6s) from the end of the playlist + assert.equal(Playlist.safeLiveIndex(playlist), 3, + 'correct media index for variable segment durations'); +}); + +QUnit.test('safeLiveIndex is 0 when no safe live point', function(assert) { + const playlist = { + targetDuration: 6, + mediaSequence: 10, + syncInfo: { + time: 0, + mediaSequence: 10 + }, + segments: [ + { + duration: 6 + }, + { + duration: 3 + }, + { + duration: 3 + } + ] + }; + + assert.equal(Playlist.safeLiveIndex(playlist), 0, + 'returns media index 0 when playlist has no safe live point'); +}); + QUnit.test( 'seekable end and playlist end account for non-zero starting VOD media sequence', function(assert) { diff --git a/test/segment-loader.test.js b/test/segment-loader.test.js index 7eecda70..e7420948 100644 --- a/test/segment-loader.test.js +++ b/test/segment-loader.test.js @@ -1,7 +1,8 @@ import QUnit from 'qunit'; import { default as SegmentLoader, - illegalMediaSwitch + illegalMediaSwitch, + safeBackBufferTrimTime } from '../src/segment-loader'; import videojs from 'video.js'; import mp4probe from 'mux.js/lib/mp4/probe'; @@ -107,6 +108,27 @@ QUnit.test('illegalMediaSwitch detects illegal media switches', function(assert) 'error when video only to audio only'); }); +QUnit.test('safeBackBufferTrimTime determines correct safe removeToTime', +function(assert) { + let seekable = videojs.createTimeRanges([[75, 120]]); + let targetDuration = 10; + let currentTime = 70; + + assert.equal(safeBackBufferTrimTime(seekable, currentTime, targetDuration), 40, + 'uses 30s before current time if currentTime is before seekable start'); + + currentTime = 110; + + assert.equal(safeBackBufferTrimTime(seekable, currentTime, targetDuration), 75, + 'uses seekable start if currentTime is after seekable start'); + + currentTime = 80; + + assert.equal(safeBackBufferTrimTime(seekable, currentTime, targetDuration), 70, + 'uses target duration before currentTime if currentTime is after seekable but' + + 'within target duration'); +}); + QUnit.module('SegmentLoader', function(hooks) { hooks.beforeEach(LoaderCommonHooks.beforeEach); hooks.afterEach(LoaderCommonHooks.afterEach); diff --git a/test/videojs-contrib-hls.test.js b/test/videojs-contrib-hls.test.js index 6ff3724b..c9288797 100644 --- a/test/videojs-contrib-hls.test.js +++ b/test/videojs-contrib-hls.test.js @@ -451,6 +451,7 @@ QUnit.test('translates seekable by the starting time for live playlists', functi 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' +