-
Notifications
You must be signed in to change notification settings - Fork 77
Description
Video Player Root Cause Report
Issue: The route video player is slow, laggy, and intermittently fails to load when opening a route.
Route Playback Flow
-
Selecting a drive dispatches
pushTimelineRange(...)fromsrc/components/Dashboard/DriveListItem.jsx:87. -
pushTimelineRangedispatchesTIMELINE_PUSH_SELECTION, which setscurrentRoute(src/actions/index.js:196-201,src/reducers/globalState.js:291-330). -
pushTimelineRangethen callsupdateTimeline(...), which triggersresetPlayback()when the timeline bounds change (src/actions/index.js:204,src/actions/index.js:163-167). -
resetPlayback()setsisBufferingVideo: true(src/timeline/playback.js:64-75). -
DriveViewrendersMedia, andMediarendersDriveVideo(src/components/DriveView/index.jsx:75-79,src/components/DriveView/Media.jsx:542-547). -
DriveVideo.updateVideoSource()sets the stream URL and immediately callssyncVideo()(src/components/DriveVideo/index.jsx:196-200).
Most Defensible Root Cause
From everything that I looked through, I found the strongest possible root cause to be an overly strict buffering exit condition in DriveVideo.syncVideo().
File: src/components/DriveVideo/index.jsx
Current logic (230-238):
const { hasLoaded } = getVideoState(videoPlayer);
if (isBufferingVideo && internalPlayer.readyState >= 4) {
dispatch(bufferVideo(false));
} else if (isBufferingVideo || !hasLoaded || internalPlayer.readyState < 2) {
if (!isBufferingVideo) {
dispatch(bufferVideo(true));
}
newPlaybackRate = 0;
}Why this is an issue
-
isBufferingVideostartstrue(src/initialState.js:17). -
While buffering, exit requires
readyState >= 4. -
If
readyState < 4, theelse ifbranch is guaranteed true because it starts withisBufferingVideo || .... -
That branch forces
newPlaybackRate = 0. -
In HLS mode,
newPlaybackRate = 0triggersinternalPlayer.pause()(src/components/DriveVideo/index.jsx:240-243).
The result: the startup/recovery is gated by a strict readyState >= 4 requirement, and the player is repeatedly held at rate 0 until that condition is met.
This freezes the timeline progression globally, not just video
isBufferingVideo zeroes the effective play speed in both currentOffset() and the playback reducer's loop normalization:
src/timeline/index.js:19:const playSpeed = state.isBufferingVideo ? 0 : state.desiredPlaySpeed;src/timeline/playback.js:90: same pattern here
Since the currentOffset() is the primary driver of playhead progression for timeline position (used by the timeline cursor, loop bounds, and video sync), the readyState >= 4 gate doesn't just pause the video, it freezes the entire UI.
Regression Proof
Commit: 086d8703b696ba86cfe5396e030b46d93c827440
Parent: da834ceb6b26263bd2d457f840493043be1ab49c
Parent behavior (da834ceb, DriveVideo.syncVideo):
- Used
hasSufficientBufferandreadyState >= 2to clear buffering.
const sufficientBuffer = Math.min(videoPlayer.getDuration() - videoPlayer.getCurrentTime(), 30);
const { hasLoaded, bufferRemaining } = getVideoState(videoPlayer);
const hasSufficientBuffer = bufferRemaining >= sufficientBuffer;
if (isBufferingVideo && hasSufficientBuffer && internalPlayer.readyState >= 2) {
dispatch(bufferVideo(false));
}Current behavior (086d870 and later):
- Removed
hasSufficientBuffer - Raised exit threshold to
readyState >= 4
This change is directly visible in the commit diff.
The nuance:
-
This is strictly tighter on
readyState(from>=2to>=4). -
It also removed the explicit
hasSufficientBufferrequirement, so strictness changed by dimension rather than being universally stricter in every possible state.
Browser Semantics
MDN defines HTMLMediaElement.readyState as:
2(HAVE_CURRENT_DATA): current frame data available3(HAVE_FUTURE_DATA): some future data available4(HAVE_ENOUGH_DATA): enough data and download rate to play through without interruption
Source:
This supports the claim that requiring >=4 is stricter than requiring >=2.
Secondary Contributors to Slow Startup
These increase the route-open load time and can even worsen startup timing:
-
Parallel
events.jsonfan-out viaPromise.allper segmentsrc/actions/cached.js:309-329
-
Parallel
coords.jsonfan-out viaPromise.allper segmentsrc/actions/cached.js:467-489
-
Eager thumbnail sprite requests from
route.urlsrc/components/Timeline/thumbnails.jsx:39src/components/Timeline/thumbnails.jsx:97
Also, route URLs are normalized to Azure Edge CDN:
src/actions/index.js:83
Additional Findings
-
Additional buffering clear path outside
syncVideo()bufferVideo(false)is also dispatched when mobile/smaller layout is on map view:src/components/DriveView/Media.jsx:268-270
-
Additional buffering-enter paths
bufferVideo(true)is dispatched from:onVideoBuffering(src/components/DriveVideo/index.jsx:104,src/components/DriveVideo/index.jsx:114-116)onHlsError(src/components/DriveVideo/index.jsx:124)onVideoError(src/components/DriveVideo/index.jsx:167)
-
Buffering is re-armed by timeline updates
- Route/timeline transitions call
resetPlayback(), which setsisBufferingVideo: true: src/actions/index.js:162-166src/timeline/playback.js:64-71
- Route/timeline transitions call
-
onBufferEndandonPlaydo not clear buffering state- They call
onVideoResume, which only clearsvideoError: src/components/DriveVideo/index.jsx:181-184src/components/DriveVideo/index.jsx:317-319- So buffering state exit is effectively controlled by
syncVideo()(plus the map view override I wrote about above)
- They call
-
Missing early return in
onVideoBuffering()src/components/DriveVideo/index.jsx:100-117- The guard at lines 103-105 dispatches
bufferVideo(true)when!videoPlayer || !currentRoute || !videoPlayer.getDuration(), but it does not return. - Execution falls through to
videoPlayer.seekTo()on line 109 andgetVideoState(videoPlayer)on line 112, which can throw ifvideoPlayeris null. - A second
bufferVideo(true)can fire at line 115 in the same invocation.
-
No explicit HLS stall recovery
src/components/DriveVideo/index.jsx:122-129onHlsError()handlesbufferStalledErrorandbufferNudgeOnStallby dispatchingbufferVideo(true)and returning silently.- There is no call to
hls.recoverMediaError()or any other HLS.js recovery method. - The HLS instance is accessible via
videoPlayer.getInternalPlayer('hls')(used insyncVideo()at line 240) but is not used in the error handler. - Under marginal network conditions, stalls have no active recovery path and can rely on the
readyState >= 4gate insyncVideo()to eventually clear (the only otherbufferVideo(false)path is the map view override inMedia.jsx:268-270, which is a UX shortcut, not a stall recovery mechanism).
-
Redundant
bufferVideo(true)dispatches churn stateACTION_BUFFER_VIDEOalways rewritesoffsetandstartTime(src/timeline/playback.js:56-62), even whenisBufferingVideois alreadytrue.DriveVideosubscribes to bothoffsetandstartTime(src/components/DriveVideo/index.jsx:329-330), so each redundant dispatch triggers a re-render ->componentDidUpdate->syncVideo()(src/components/DriveVideo/index.jsx:88-91).- Multiple call sites can fire while already buffering (lines 104, 115, 124, 167), creating a feedback loop of state writes and re-renders.
- Exact performance impact is runtime-dependent, but the structural churn is provable from the code alone.
-
ReactPlayer
playingprop conflicts with manualpause()(plausible amplifier)src/components/DriveVideo/index.jsx:307passesplaying={Boolean(currentRoute && desiredPlaySpeed)}to ReactPlayer.- After
resetPlayback(),desiredPlaySpeed = 1, soplayingevaluates totrue. - Meanwhile
syncVideo()forcesinternalPlayer.pause()at line 241 whenisBufferingVideois true. - ReactPlayer internally manages play/pause state based on its
playingprop and may call.play()on re-render, competing with the manual.pause(). - Evidence caveat: the
.catch()at line 249 does handle the manual.play()call at line 247, not the prop-driven path, so it is not direct proof of this conflict by itself. The conflict is structurally present but not directly observable from the code alone.
Thoughts Downgraded to Inference
The following statements are what seem plausible to me, but are not 100% proven:
- "Hard deadlock" in all browsers.
- Verified: strict gating + forced pause path exists.
- Not verified here: that paused state always prevents progression to
readyState 4.
- "Browser deprioritizes media fetch when paused."
- Commit notes actually mention iOS cases where pausing can help readyState updates.
Final Notes
From everything that I found, to sum things up:
-
The buffering-exit logic changed from
hasSufficientBuffer && readyState >= 2toreadyState >= 4. -
The current state machine keeps forcing rate 0/pause while buffering unless
readyStatereaches 4. -
This is consistent with slower startup and more visible lag under marginal loading conditions.