Summary
Since 1.14.0, a clipped video (clipStartTime > 0) seeks to its own end on first play and on every replay, then pauses on the last frame. 1.13.1 and earlier play the clip.
Reproduction
Native provider, any file, clip window later in the file than the window is long (typical for a highlight clip):
<script type="module" src="https://cdn.vidstack.io/player@1.15.6"></script>
<media-player src="https://files.vidstack.io/sprite-fight/720p.mp4" clip-start-time="30" clip-end-time="33" autoplay muted playsinline>
<media-provider></media-provider>
</media-player>
1.5 s after load, headless Chromium:
| build |
media-seek-request detail |
video.currentTime |
paused |
| 1.12.13 |
30 |
30.97 (playing) |
false |
| 1.15.6 |
30 |
33.00 |
true |
| 1.15.6 + fix below |
0 |
31.12 (playing) |
false |
Same result with clip-start-time="6" clip-end-time="9" on a local 10 s file. Replay after the clip ends is broken in both Chromium and WebKit on 1.15.6, via remote.play(), PlayButton, keyboard, and MediaSession.
Cause
MediaStateManager#resetPlaybackIfNeeded dispatches the reset seek with an absolute time:
this.dispatch('media-seek-request', { detail: seekableStart(), trigger });
MediaRequestManager['media-seek-request'] runs the detail through boundTime, which treats it as clip-relative (every other caller sends clip-relative: remote.seek, TimeSlider, MediaSession) and adds clipStartTime again:
const clippedTime = time + store.clipStartTime(),
isStart = clippedTime <= store.seekableStart(),
isEnd = clippedTime >= store.seekableEnd();
With clipStartTime = 30, seekableStart = 30, seekableEnd = 33: clippedTime = 60, isEnd is true, the provider seeks to 33, the next time-update is >= clipEndTime, and the state manager requests a pause.
The unit mismatch has existed since 95a3dad (fix(player): clipping broken, v1.12.6, #1382), which changed the detail from (clipStartTime() > 0 ? 0 : seekableStart()) + 0.1 to seekableStart(). It was hidden until 923e7c7 (fix(vidstack): preserve sub-second seek targets, #1824, v1.14.0), because the old check Math.floor(time) === Math.floor(store.seekableStart()) matched the absolute value and returned seekableStart() unchanged.
Why it fires on first play
In Chromium the media-fragment seeked (from the #t=30,33 source URL) arrives before can-play. At that point seekableEnd is still 0 and currentTime is 0, so the ['seeked'] handler's Math.floor(currentTime()) === Math.floor(seekableEnd()) branch calls end() and marks the clip ended before it has played. The play handler then sees ended() and resets. WebKit does not fire that early seeked, so first play works there, but every replay path resets (ended(), realCurrentTime() >= clipEndTime(), or |currentTime - duration| < 0.1) and hits the same double add.
After the bad seek, ended is left false (the seeked at 33 clears it), so a consumer cannot work around it by pre-seeking on ended.
Fix
this.dispatch('media-seek-request', {
detail: seekableStart() - clipStartTime(),
trigger,
});
Verified by patching the 1.15.6 CDN bundle: first play, replay via remote.play(), and seek(0) + play() all play the clip in Chromium and WebKit. An alternative is to bypass boundTime and call provider.setCurrentTime(seekableStart()), as seekToLiveEdge already does.
The ['seeked'] handler comparing clip-relative currentTime with absolute seekableEnd is a second unit mix in the same area; it is what produces the load-time ended above.
Summary
Since 1.14.0, a clipped video (
clipStartTime> 0) seeks to its own end on first play and on every replay, then pauses on the last frame. 1.13.1 and earlier play the clip.Reproduction
Native provider, any file, clip window later in the file than the window is long (typical for a highlight clip):
1.5 s after load, headless Chromium:
media-seek-requestdetailvideo.currentTimepausedSame result with
clip-start-time="6" clip-end-time="9"on a local 10 s file. Replay after the clip ends is broken in both Chromium and WebKit on 1.15.6, viaremote.play(), PlayButton, keyboard, and MediaSession.Cause
MediaStateManager#resetPlaybackIfNeededdispatches the reset seek with an absolute time:MediaRequestManager['media-seek-request']runs the detail throughboundTime, which treats it as clip-relative (every other caller sends clip-relative:remote.seek,TimeSlider, MediaSession) and addsclipStartTimeagain:With
clipStartTime = 30,seekableStart = 30,seekableEnd = 33:clippedTime = 60,isEndis true, the provider seeks to 33, the nexttime-updateis>= clipEndTime, and the state manager requests a pause.The unit mismatch has existed since 95a3dad (
fix(player): clipping broken, v1.12.6, #1382), which changed the detail from(clipStartTime() > 0 ? 0 : seekableStart()) + 0.1toseekableStart(). It was hidden until 923e7c7 (fix(vidstack): preserve sub-second seek targets, #1824, v1.14.0), because the old checkMath.floor(time) === Math.floor(store.seekableStart())matched the absolute value and returnedseekableStart()unchanged.Why it fires on first play
In Chromium the media-fragment
seeked(from the#t=30,33source URL) arrives beforecan-play. At that pointseekableEndis still 0 andcurrentTimeis 0, so the['seeked']handler'sMath.floor(currentTime()) === Math.floor(seekableEnd())branch callsend()and marks the clip ended before it has played. Theplayhandler then seesended()and resets. WebKit does not fire that earlyseeked, so first play works there, but every replay path resets (ended(),realCurrentTime() >= clipEndTime(), or|currentTime - duration| < 0.1) and hits the same double add.After the bad seek,
endedis leftfalse(theseekedat 33 clears it), so a consumer cannot work around it by pre-seeking onended.Fix
Verified by patching the 1.15.6 CDN bundle: first play, replay via
remote.play(), andseek(0)+play()all play the clip in Chromium and WebKit. An alternative is to bypassboundTimeand callprovider.setCurrentTime(seekableStart()), asseekToLiveEdgealready does.The
['seeked']handler comparing clip-relativecurrentTimewith absoluteseekableEndis a second unit mix in the same area; it is what produces the load-timeendedabove.