From 74b28e8c6fd4f1877021744a126a884171e19c9b Mon Sep 17 00:00:00 2001 From: Garrett Singer Date: Fri, 21 Dec 2018 07:25:51 -0800 Subject: [PATCH] Add allowSeeksWithinUnsafeLiveWindow property (#320) * Add allowSeeksWithinUnsafeLiveWindow property This property will prevent the playback watcher from attempting to correct seeks that fall outside of the safe live window, but still fall within the playlist boundaries. It can help resolve issues where the playback watcher's gap skipper tries to seek past a gap, but the playback watcher then detects an illegal seek and corrects it to the safe live point, which, being content already played, leads to repeated content. --- README.md | 20 ++++ src/playback-watcher.js | 29 ++++- src/videojs-http-streaming.js | 3 +- test/playback-watcher.test.js | 206 +++++++++++++++++++++++++++++++--- 4 files changed, 238 insertions(+), 20 deletions(-) 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'); });