diff --git a/CHANGELOG.md b/CHANGELOG.md index 1932f9ed8..6b7eb288d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ + +## [3.13.3](https://github.com/videojs/http-streaming/compare/v3.13.2...v3.13.3) (2024-08-12) + +### Bug Fixes + +* audio segment on incorrect timeline ([#1530](https://github.com/videojs/http-streaming/issues/1530)) ([876ed8c](https://github.com/videojs/http-streaming/commit/876ed8c)) + ## [3.13.2](https://github.com/videojs/http-streaming/compare/v3.13.1...v3.13.2) (2024-07-22) diff --git a/package-lock.json b/package-lock.json index 427047f4c..471e72b0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@videojs/http-streaming", - "version": "3.13.2", + "version": "3.13.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 19efbefbf..de6cf4557 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@videojs/http-streaming", - "version": "3.13.2", + "version": "3.13.3", "description": "Play back HLS and DASH with Video.js, even where it's not natively supported", "main": "dist/videojs-http-streaming.cjs.js", "module": "dist/videojs-http-streaming.es.js", diff --git a/src/playlist-controller.js b/src/playlist-controller.js index 2d084fec1..76106be1a 100644 --- a/src/playlist-controller.js +++ b/src/playlist-controller.js @@ -937,6 +937,28 @@ export class PlaylistController extends videojs.EventTarget { this.onEndOfStream(); }); + // In DASH, there is the possibility of the video segment and the audio segment + // at a current time to be on different timelines. When this occurs, the player + // forwards playback to a point where these two segment types are back on the same + // timeline. This time will be just after the end of the audio segment that is on + // a previous timeline. + if (this.sourceType_ === 'dash') { + this.timelineChangeController_.on('audioTimelineBehind', () => { + const segmentInfo = this.audioSegmentLoader_.pendingSegment_; + + if (!segmentInfo || !segmentInfo.segment || !segmentInfo.segment.syncInfo) { + return; + } + + // Update the current time to just after the faulty audio segment. + // This moves playback to a spot where both audio and video segments + // are on the same timeline. + const newTime = segmentInfo.segment.syncInfo.end + 0.01; + + this.tech_.setCurrentTime(newTime); + }); + } + this.mainSegmentLoader_.on('earlyabort', (event) => { // never try to early abort with the new ABR algorithm if (this.bufferBasedABR) { diff --git a/src/segment-loader.js b/src/segment-loader.js index c37e180a0..b98321d6a 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -423,15 +423,68 @@ export const shouldFixBadTimelineChanges = (timelineChangeController) => { return false; }; +/** + * Fixes certain bad timeline scenarios by resetting the loader. + * + * @param {SegmentLoader} segmentLoader + */ export const fixBadTimelineChange = (segmentLoader) => { if (!segmentLoader) { return; } + segmentLoader.pause(); segmentLoader.resetEverything(); segmentLoader.load(); }; +/** + * Check if the pending audio timeline change is behind the + * pending main timeline change. + * + * @param {SegmentLoader} segmentLoader + * @return {boolean} + */ +const isAudioTimelineBehind = (segmentLoader) => { + const pendingAudioTimelineChange = segmentLoader.timelineChangeController_.pendingTimelineChange({ type: 'audio' }); + const pendingMainTimelineChange = segmentLoader.timelineChangeController_.pendingTimelineChange({ type: 'main' }); + const hasPendingTimelineChanges = pendingAudioTimelineChange && pendingMainTimelineChange; + + return hasPendingTimelineChanges && pendingAudioTimelineChange.to < pendingMainTimelineChange.to; +}; + +/** + * A method to check if the player is waiting for a timeline change, and fixes + * certain scenarios where the timelines need to be updated. + * + * @param {SegmentLoader} segmentLoader + */ +const checkAndFixTimelines = (segmentLoader) => { + const segmentInfo = segmentLoader.pendingSegment_; + + if (!segmentInfo) { + return; + } + + const waitingForTimelineChange = shouldWaitForTimelineChange({ + timelineChangeController: segmentLoader.timelineChangeController_, + currentTimeline: segmentLoader.currentTimeline_, + segmentTimeline: segmentInfo.timeline, + loaderType: segmentLoader.loaderType_, + audioDisabled: segmentLoader.audioDisabled_ + }); + + if (waitingForTimelineChange && shouldFixBadTimelineChanges(segmentLoader.timelineChangeController_)) { + // Audio being behind should only happen on DASH sources. + if (segmentLoader.sourceType_ === 'dash' && isAudioTimelineBehind(segmentLoader)) { + segmentLoader.timelineChangeController_.trigger('audioTimelineBehind'); + return; + } + + fixBadTimelineChange(segmentLoader); + } +}; + export const mediaDuration = (timingInfos) => { let maxDuration = 0; @@ -698,6 +751,8 @@ export default class SegmentLoader extends videojs.EventTarget { this.sourceUpdater_.on('ready', () => { if (this.hasEnoughInfoToAppend_()) { this.processCallQueue_(); + } else { + checkAndFixTimelines(this); } }); @@ -712,6 +767,8 @@ export default class SegmentLoader extends videojs.EventTarget { this.timelineChangeController_.on('pendingtimelinechange', () => { if (this.hasEnoughInfoToAppend_()) { this.processCallQueue_(); + } else { + checkAndFixTimelines(this); } }); } @@ -723,9 +780,13 @@ export default class SegmentLoader extends videojs.EventTarget { this.trigger({type: 'timelinechange', ...metadata }); if (this.hasEnoughInfoToLoad_()) { this.processLoadQueue_(); + } else { + checkAndFixTimelines(this); } if (this.hasEnoughInfoToAppend_()) { this.processCallQueue_(); + } else { + checkAndFixTimelines(this); } }); } @@ -1961,6 +2022,8 @@ Fetch At Buffer: ${this.fetchAtBuffer_} // check if any calls were waiting on the track info if (this.hasEnoughInfoToAppend_()) { this.processCallQueue_(); + } else { + checkAndFixTimelines(this); } } @@ -1981,6 +2044,8 @@ Fetch At Buffer: ${this.fetchAtBuffer_} // check if any calls were waiting on the timing info if (this.hasEnoughInfoToAppend_()) { this.processCallQueue_(); + } else { + checkAndFixTimelines(this); } } @@ -2156,9 +2221,6 @@ Fetch At Buffer: ${this.fetchAtBuffer_} audioDisabled: this.audioDisabled_ }) ) { - if (shouldFixBadTimelineChanges(this.timelineChangeController_)) { - fixBadTimelineChange(this); - } return false; } @@ -2219,9 +2281,6 @@ Fetch At Buffer: ${this.fetchAtBuffer_} audioDisabled: this.audioDisabled_ }) ) { - if (shouldFixBadTimelineChanges(this.timelineChangeController_)) { - fixBadTimelineChange(this); - } return false; } @@ -2238,6 +2297,8 @@ Fetch At Buffer: ${this.fetchAtBuffer_} // If there's anything in the call queue, then this data came later and should be // executed after the calls currently queued. if (this.callQueue_.length || !this.hasEnoughInfoToAppend_()) { + checkAndFixTimelines(this); + this.callQueue_.push(this.handleData_.bind(this, simpleSegment, result)); return; } @@ -2627,6 +2688,8 @@ Fetch At Buffer: ${this.fetchAtBuffer_} } if (!this.hasEnoughInfoToLoad_()) { + checkAndFixTimelines(this); + this.loadQueue_.push(() => { // regenerate the audioAppendStart, timestampOffset, etc as they // may have changed since this function was added to the queue. diff --git a/test/playlist-controller.test.js b/test/playlist-controller.test.js index b1ff4f61e..9cbe9391c 100644 --- a/test/playlist-controller.test.js +++ b/test/playlist-controller.test.js @@ -2648,6 +2648,52 @@ QUnit.test( } ); +QUnit.test( + 'setCurrentTime is not called on audioTimelineBehind when there is no pending segment', + function(assert) { + const options = { + src: 'test', + tech: this.player.tech_, + sourceType: 'dash', + player_: this.player + }; + const pc = new PlaylistController(options); + const tech = this.player.tech_; + const setCurrentTimeSpy = sinon.spy(tech, 'setCurrentTime'); + + pc.timelineChangeController_.trigger('audioTimelineBehind'); + + assert.notOk(setCurrentTimeSpy.called, 'setCurrentTime not called'); + } +); + +QUnit.test( + 'setCurrentTime to after audio segment when audioTimelineBehind is triggered', + function(assert) { + const options = { + src: 'test', + tech: this.player.tech_, + sourceType: 'dash', + player_: this.player + }; + const pc = new PlaylistController(options); + const tech = this.player.tech_; + const setCurrentTimeSpy = sinon.spy(tech, 'setCurrentTime'); + + pc.audioSegmentLoader_.pendingSegment_ = { + segment: { + syncInfo: { + end: 10 + } + } + }; + + pc.timelineChangeController_.trigger('audioTimelineBehind'); + + assert.ok(setCurrentTimeSpy.calledWith(10.01), 'sets current time to just after the end of the audio segment'); + } +); + QUnit.test('calls to update cues on new media', function(assert) { const origVhsOptions = videojs.options.vhs; diff --git a/test/segment-loader.test.js b/test/segment-loader.test.js index 3b5063935..dafdbd506 100644 --- a/test/segment-loader.test.js +++ b/test/segment-loader.test.js @@ -1663,7 +1663,7 @@ QUnit.module('SegmentLoader', function(hooks) { }); }); - QUnit.test('hasEnoughInfoToLoad_ calls fixBadTimelineChange', function(assert) { + QUnit.test('fixBadTimelineChange on load', function(assert) { loader.dispose(); loader = new SegmentLoader(LoaderCommonSettings.call(this, { loaderType: 'audio' @@ -1722,7 +1722,7 @@ QUnit.module('SegmentLoader', function(hooks) { }); }); - QUnit.test('hasEnoughInfoToAppend_ calls fixBadTimelineChange', function(assert) { + QUnit.test('fixBadTimelineChange on append', function(assert) { loader.dispose(); loader = new SegmentLoader(LoaderCommonSettings.call(this, { loaderType: 'main' @@ -1789,6 +1789,48 @@ QUnit.module('SegmentLoader', function(hooks) { }); }); + QUnit.test('triggers event when DASH audio timeline is behind main', function(assert) { + loader.dispose(); + loader = new SegmentLoader(LoaderCommonSettings.call(this, { + loaderType: 'audio', + sourceType: 'dash' + }), {}); + + loader.timelineChangeController_.pendingTimelineChange = ({ type }) => { + if (type === 'audio') { + return { + from: 0, + to: 5 + }; + } else if (type === 'main') { + return { + from: 0, + to: 10 + }; + } + }; + + const triggerSpy = sinon.spy(loader.timelineChangeController_, 'trigger'); + + const playlist = playlistWithDuration(20); + + playlist.discontinuityStarts = [1]; + loader.getCurrentMediaInfo_ = () => { + return { + hasVideo: true, + hasAudio: true, + isMuxed: true + }; + }; + + return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_).then(() => { + loader.playlist(playlist); + loader.load(); + this.clock.tick(1); + assert.ok(triggerSpy.calledWith('audioTimelineBehind'), 'audio timeline behind event is triggered'); + }); + }); + QUnit.test('audio loader does not wait to request segment even if timestamp offset is nonzero', function(assert) { loader.dispose(); loader = new SegmentLoader(LoaderCommonSettings.call(this, {