diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 5575fb4b25d..fa9e1d6d487 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -134,7 +134,7 @@ export default class BaseStreamController this.state = State.STOPPED; } - protected _streamEnded(bufferInfo, levelDetails) { + protected _streamEnded(bufferInfo, levelDetails: LevelDetails) { const { fragCurrent, fragmentTracker } = this; // we just got done loading the final fragment and there is no other buffered range after ... // rationale is that in case there are any buffered ranges after, it means that there are unbuffered portion in between @@ -142,9 +142,28 @@ export default class BaseStreamController if ( !levelDetails.live && fragCurrent && - fragCurrent.sn === levelDetails.endSN && + // NOTE: Because of the way parts are currently parsed/represented in the playlist, we can end up + // in situations where the current fragment is actually greater than levelDetails.endSN. While + // this feels like the "wrong place" to account for that, this is a narrower/safer change than + // updating e.g. M3U8Parser::parseLevelPlaylist(). + fragCurrent.sn >= levelDetails.endSN && !bufferInfo.nextStart ) { + const partList = levelDetails.partList; + // Since the last part isn't guaranteed to correspond to fragCurrent for ll-hls, check instead if the last part is buffered. + if (partList?.length) { + const lastPart = partList[partList.length - 1]; + + // Checking the midpoint of the part for potential margin of error and related issues. + // NOTE: Technically I believe parts could yield content that is < the computed duration (including potential a duration of 0) + // and still be spec-compliant, so there may still be edge cases here. Likewise, there could be issues in end of stream + // part mismatches for independent audio and video playlists/segments. + const lastPartBuffered = BufferHelper.isBuffered( + this.media, + lastPart.start + lastPart.duration / 2 + ); + return lastPartBuffered; + } const fragState = fragmentTracker.getState(fragCurrent); return ( fragState === FragmentState.PARTIAL || fragState === FragmentState.OK diff --git a/tests/mocks/time-ranges.mock.js b/tests/mocks/time-ranges.mock.js new file mode 100644 index 00000000000..0ff7e3c793f --- /dev/null +++ b/tests/mocks/time-ranges.mock.js @@ -0,0 +1,36 @@ +const assertValidRange = (name, length, index) => { + if (index >= length || index < 0) { + throw new DOMException( + `Failed to execute '${name}' on 'TimeRanges': The index provided (${index}) is greater than the maximum bound (${length}).` + ); + } + return true; +}; + +export class TimeRangesMock { + _ranges = []; + + // Accepts an argument list of [start, end] tuples or { start: number, end: number } objects + constructor(...ranges) { + this._ranges = ranges.map((range) => + Array.isArray(range) ? range : [range.start, range.end] + ); + } + + get length() { + const { _ranges: ranges } = this; + return ranges.length; + } + + start(i) { + const { _ranges: ranges, length } = this; + assertValidRange('start', length, i); + return ranges[i] && ranges[i][0]; + } + + end(i) { + const { _ranges: ranges, length } = this; + assertValidRange('end', length, i); + return ranges[i] && ranges[i][1]; + } +} diff --git a/tests/unit/controller/base-stream-controller.js b/tests/unit/controller/base-stream-controller.js index 8f48a67e8de..119c28e9712 100644 --- a/tests/unit/controller/base-stream-controller.js +++ b/tests/unit/controller/base-stream-controller.js @@ -1,6 +1,7 @@ import BaseStreamController from '../../../src/controller/stream-controller'; import Hls from '../../../src/hls'; import { FragmentState } from '../../../src/controller/fragment-tracker'; +import { TimeRangesMock } from '../../mocks/time-ranges.mock'; describe('BaseStreamController', function () { let baseStreamController; @@ -75,5 +76,25 @@ describe('BaseStreamController', function () { `fragState is ${fragmentTracker.getState()}, expecting OK` ).to.be.true; }); + + it('returns true if parts are buffered for low latency content', function () { + media.buffered = new TimeRangesMock([0, 1]); + baseStreamController.fragCurrent = { sn: 10 }; + levelDetails.endSN = 10; + levelDetails.partList = [{ start: 0, duration: 1 }]; + + expect(baseStreamController._streamEnded(bufferInfo, levelDetails)).to.be + .true; + }); + + it('returns true even if fragCurrent is after the last fragment due to low latency content modeling', function () { + media.buffered = new TimeRangesMock([0, 1]); + baseStreamController.fragCurrent = { sn: 11 }; + levelDetails.endSN = 10; + levelDetails.partList = [{ start: 0, duration: 1 }]; + + expect(baseStreamController._streamEnded(bufferInfo, levelDetails)).to.be + .true; + }); }); });