diff --git a/README.md b/README.md index 6111e2057..e29ec60d4 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Video.js Compatibility: 6.0, 7.0 - [enableLowInitialPlaylist](#enablelowinitialplaylist) - [limitRenditionByPlayerDimensions](#limitrenditionbyplayerdimensions) - [smoothQualityChange](#smoothqualitychange) + - [allowSeeksWithinUnsafeLiveWindow](#allowSeeksWithinUnsafeLiveWindow) - [Runtime Properties](#runtime-properties) - [hls.playlists.master](#hlsplaylistsmaster) - [hls.playlists.media](#hlsplaylistsmedia) @@ -374,6 +375,25 @@ Note that this _only_ affects quality changes triggered via the representations API; automatic quality switches based on available bandwidth will always be smooth switches. +##### allowSeeksWithinUnsafeLiveWindow +* Type: `boolean` +* can be used as a source option + +When `allowSeeksWithinUnsafeLiveWindow` is set to `true`, if the active playlist is live +and a seek is made to a time between the safe live point (end of manifest minus three +times the target duration, +see [the hls spec](https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-6.3.3) +for details) and the end of the playlist, the seek is allowed, rather than corrected to +the safe live point. + +This option can help in instances where the live stream's target duration is greater than +the segment durations, playback ends up in the unsafe live window, and there are gaps in +the content. In this case the player will attempt to seek past the gaps but end up seeking +inside of the unsafe range, leading to a correction and seek back into a previously played +content. + +The property defaults to `false`. + ### Runtime Properties Runtime properties are attached to the tech object when HLS is in use. You can get a reference to the HLS source handler like this: diff --git a/src/playback-watcher.js b/src/playback-watcher.js index 3accdd250..692194975 100644 --- a/src/playback-watcher.js +++ b/src/playback-watcher.js @@ -34,6 +34,8 @@ export default class PlaybackWatcher { this.tech_ = options.tech; this.seekable = options.seekable; this.seekTo = options.seekTo; + this.allowSeeksWithinUnsafeLiveWindow = options.allowSeeksWithinUnsafeLiveWindow; + this.media = options.media; this.consecutiveUpdates = 0; this.lastRecordedTime = null; @@ -153,18 +155,29 @@ export default class PlaybackWatcher { */ fixesBadSeeks_() { const seeking = this.tech_.seeking(); + + if (!seeking) { + return false; + } + const seekable = this.seekable(); const currentTime = this.tech_.currentTime(); + const isAfterSeekableRange = this.afterSeekableWindow_( + seekable, + currentTime, + this.media(), + this.allowSeeksWithinUnsafeLiveWindow + ); let seekTo; - if (seeking && this.afterSeekableWindow_(seekable, currentTime)) { + if (isAfterSeekableRange) { const seekableEnd = seekable.end(seekable.length - 1); // sync to live point (if VOD, our seekable was updated and we're simply adjusting) seekTo = seekableEnd; } - if (seeking && this.beforeSeekableWindow_(seekable, currentTime)) { + if (this.beforeSeekableWindow_(seekable, currentTime)) { const seekableStart = seekable.start(0); // sync to the beginning of the live window @@ -290,13 +303,21 @@ export default class PlaybackWatcher { return false; } - afterSeekableWindow_(seekable, currentTime) { + afterSeekableWindow_( + seekable, currentTime, playlist, allowSeeksWithinUnsafeLiveWindow = false) { if (!seekable.length) { // we can't make a solid case if there's no seekable, default to false return false; } - if (currentTime > seekable.end(seekable.length - 1) + Ranges.SAFE_TIME_DELTA) { + let allowedEnd = seekable.end(seekable.length - 1) + Ranges.SAFE_TIME_DELTA; + const isLive = !playlist.endList; + + if (isLive && allowSeeksWithinUnsafeLiveWindow) { + allowedEnd = seekable.end(seekable.length - 1) + (playlist.targetDuration * 3); + } + + if (currentTime > allowedEnd) { return true; } diff --git a/src/videojs-http-streaming.js b/src/videojs-http-streaming.js index b59bd354d..d81e18a6e 100644 --- a/src/videojs-http-streaming.js +++ b/src/videojs-http-streaming.js @@ -468,7 +468,8 @@ class HlsHandler extends Component { this.masterPlaylistController_ = new MasterPlaylistController(this.options_); this.playbackWatcher_ = new PlaybackWatcher( videojs.mergeOptions(this.options_, { - seekable: () => this.seekable() + seekable: () => this.seekable(), + media: () => this.masterPlaylistController_.media() })); this.masterPlaylistController_.on('error', () => { diff --git a/test/playback-watcher.test.js b/test/playback-watcher.test.js index b2040fa0e..9b217b762 100644 --- a/test/playback-watcher.test.js +++ b/test/playback-watcher.test.js @@ -548,6 +548,65 @@ QUnit.test('corrects seek outside of seekable', function(assert) { assert.equal(seeks.length, 4, 'did not seek'); }); +QUnit.test('corrected seeks respect allowSeeksWithinUnsafeLiveWindow flag', +function(assert) { + // set an arbitrary live source + this.player.src({ + src: 'liveStart30sBefore.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + // start playback normally + this.player.tech_.triggerReady(); + this.clock.tick(1); + standardXHRResponse(this.requests.shift()); + openMediaSource(this.player, this.clock); + this.player.tech_.trigger('play'); + this.player.tech_.trigger('playing'); + this.clock.tick(1); + + let playbackWatcher = this.player.tech_.hls.playbackWatcher_; + let seeks = []; + let seekable; + let seeking; + let currentTime; + + playbackWatcher.seekable = () => seekable; + playbackWatcher.tech_ = { + off: () => {}, + seeking: () => seeking, + currentTime: () => currentTime, + // mocked out + paused: () => false, + buffered: () => videojs.createTimeRanges() + }; + this.player.vhs.setCurrentTime = (time) => seeks.push(time); + + playbackWatcher.allowSeeksWithinUnsafeLiveWindow = true; + + // waiting + + seekable = videojs.createTimeRanges([[1, 45]]); + seeking = true; + + // target duration of 10, seekable end of 45 + // 45 + 3 * 10 = 75 + currentTime = 75; + this.player.tech_.trigger('waiting'); + assert.equal(seeks.length, 0, 'did not seek'); + + currentTime = 75.1; + this.player.tech_.trigger('waiting'); + assert.equal(seeks.length, 1, 'seeked'); + assert.equal(seeks[0], 45, 'player seeked to live point'); + + playbackWatcher.allowSeeksWithinUnsafeLiveWindow = true; + + currentTime = 75; + this.player.tech_.trigger('waiting'); + assert.equal(seeks.length, 1, 'did not seek'); +}); + QUnit.test('calls fixesBadSeeks_ on seekablechanged', function(assert) { // set an arbitrary live source this.player.src({ @@ -675,36 +734,153 @@ QUnit.test('detects live window falloff', function(assert) { 'true if current time is 0 and earlier than seekable range'); }); -QUnit.test('detects beyond seekable window', function(assert) { +QUnit.test('detects beyond seekable window for VOD', function(assert) { + const playlist = { + endList: true, + targetDuration: 7 + }; let afterSeekableWindow_ = this.playbackWatcher.afterSeekableWindow_.bind(this.playbackWatcher); - assert.ok( - !afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8), + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8, playlist), 'false if before seekable range'); assert.ok( - afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2), + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2, playlist), 'true if after seekable range'); - assert.ok( - !afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9), + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9, playlist), 'false if within starting seekable range buffer'); - assert.ok( - !afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1), + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1, playlist), 'false if within ending seekable range buffer'); - assert.ok( - !afterSeekableWindow_(videojs.createTimeRanges(), 10), + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges(), 10, playlist), 'false if no seekable range'); - assert.ok( - !afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2), + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2, playlist), 'false if current time is negative'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5, playlist), + 'false if within seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0, playlist), + 'false if within seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10, playlist), + 'false if within seekable range'); +}); + +QUnit.test('detects beyond seekable window for LIVE', function(assert) { + // no endList means live + const playlist = { + targetDuration: 7 + }; + let afterSeekableWindow_ = + this.playbackWatcher.afterSeekableWindow_.bind(this.playbackWatcher); + + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8, playlist), + 'false if before seekable range'); assert.ok( - !afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5), + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2, playlist), + 'true if after seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9, playlist), + 'false if within starting seekable range buffer'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1, playlist), + 'false if within ending seekable range buffer'); + + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges(), 10, playlist), + 'false if no seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2, playlist), + 'false if current time is negative'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5, playlist), 'false if within seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0, playlist), + 'false if within seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10, playlist), + 'false if within seekable range'); +}); + +QUnit.test('respects allowSeeksWithinUnsafeLiveWindow flag', function(assert) { + // no endList means live + const playlist = { + targetDuration: 7 + }; + let afterSeekableWindow_ = + this.playbackWatcher.afterSeekableWindow_.bind(this.playbackWatcher); + + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8, playlist, true), + 'false if before seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2, playlist, true), + 'false if after seekable range but within unsafe live window'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 40.9, playlist, true), + 'false if after seekable range but within unsafe live window'); assert.ok( - !afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0), + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 41.1, playlist, true), + 'true if after seekable range and unsafe live window'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9, playlist, true), + 'false if within starting seekable range buffer'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1, playlist, true), + 'false if within ending seekable range buffer'); + + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges(), 10, playlist, true), + 'false if no seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2, playlist, true), + 'false if current time is negative'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5, playlist, true), + 'false if within seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0, playlist, true), 'false if within seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10, playlist, true), + 'false if within seekable range'); + + playlist.endList = true; + + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8, playlist, true), + 'false if before seekable range'); assert.ok( - !afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10), + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2, playlist, true), + 'true if after seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9, playlist, true), + 'false if within starting seekable range buffer'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1, playlist, true), + 'false if within ending seekable range buffer'); + + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges(), 10, playlist, true), + 'false if no seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2, playlist, true), + 'false if current time is negative'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5, playlist, true), + 'false if within seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0, playlist, true), + 'false if within seekable range'); + assert.notOk( + afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10, playlist, true), 'false if within seekable range'); });