From 1dd360cb1d6ee3f00ed2737906ae3a557bc29a81 Mon Sep 17 00:00:00 2001 From: Lorenzo D'Eri Date: Thu, 15 Jan 2026 15:06:55 +0100 Subject: [PATCH] fix: calculate EXT-X-START TIME-OFFSET from actual playlist end for live streams For live streams with negative TIME-OFFSET in EXT-X-START, the offset was incorrectly calculated from seekableEnd, which already has liveEdgeDelay subtracted. This caused playback to start further behind the live edge than intended. Per HLS spec, negative TIME-OFFSET should be relative to the end of the last Media Segment. This fix adds liveEdgeDelay back to seekableEnd before applying the offset, making VHS behavior consistent with native HLS implementations (e.g., Safari). Example: With liveEdgeDelay=18 and TIME-OFFSET=-18: - Before: startPoint = seekableEnd - 18 (36s behind actual live edge) - After: startPoint = seekableEnd (18s behind, matching Safari) --- src/playlist-controller.js | 10 +++++++++- test/manifests/startLiveNegative.m3u8 | 15 +++++++++++++++ test/videojs-http-streaming.test.js | 24 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 test/manifests/startLiveNegative.m3u8 diff --git a/src/playlist-controller.js b/src/playlist-controller.js index aa989d5eb..d4181509f 100644 --- a/src/playlist-controller.js +++ b/src/playlist-controller.js @@ -1221,7 +1221,15 @@ export class PlaylistController extends videojs.EventTarget { const offset = media.start.timeOffset; if (offset < 0) { - startPoint = Math.max(seekableEnd + offset, seekable.start(0)); + // Per HLS spec, negative TIME-OFFSET is from the end of the last Media Segment. + // For live streams, seekableEnd already has liveEdgeDelay subtracted, + // so we need to add it back to get the actual playlist end. + // Clamp to seekableEnd to avoid seeking into the unsafe live edge zone. + const main = this.mainPlaylistLoader_.main; + const liveEdgeDelay = Vhs.Playlist.liveEdgeDelay(main, media); + const actualPlaylistEnd = seekableEnd + liveEdgeDelay; + + startPoint = Math.max(Math.min(actualPlaylistEnd + offset, seekableEnd), seekable.start(0)); } else { startPoint = Math.min(seekableEnd, offset); } diff --git a/test/manifests/startLiveNegative.m3u8 b/test/manifests/startLiveNegative.m3u8 new file mode 100644 index 000000000..6b988fd49 --- /dev/null +++ b/test/manifests/startLiveNegative.m3u8 @@ -0,0 +1,15 @@ +#EXTM3U +#EXT-X-START:TIME-OFFSET=-30,PRECISE=YES +#EXT-X-TARGETDURATION:10 +#EXTINF:10, +media-00001.ts +#EXTINF:10, +media-00002.ts +#EXTINF:10, +media-00003.ts +#EXTINF:10, +media-00004.ts +#EXTINF:10, +media-00005.ts +#EXTINF:10, +media-00006.ts diff --git a/test/videojs-http-streaming.test.js b/test/videojs-http-streaming.test.js index 5c143ff4c..65d860b09 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -485,6 +485,30 @@ QUnit.test('seeks to negative offset point', function(assert) { assert.strictEqual(currentTime, 35, 'seeked to negative offset'); }); +QUnit.test('seeks to negative offset on live stream from actual playlist end', function(assert) { + let currentTime = 0; + + this.player.autoplay(true); + this.player.on('seeking', () => { + currentTime = this.player.currentTime(); + }); + this.player.src({ + src: 'startLiveNegative.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + this.clock.tick(1); + + openMediaSource(this.player, this.clock); + this.player.tech_.trigger('play'); + this.standardXHRResponse(this.requests.shift()); + this.clock.tick(1); + + // 6 segments * 10s = 60s total, liveEdgeDelay = 30, TIME-OFFSET = -30 + // Per HLS spec, offset is from playlist end (60s), so startPoint = 60 - 30 = 30 + assert.strictEqual(currentTime, 30, 'seeked to negative offset from actual playlist end on live stream'); +}); + QUnit.test( 'duration is set when the source opens after the playlist is loaded', function(assert) {