Skip to content

Fix video player buffering issue #583

@Willebrew

Description

@Willebrew

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

  1. Selecting a drive dispatches pushTimelineRange(...) from src/components/Dashboard/DriveListItem.jsx:87.

  2. pushTimelineRange dispatches TIMELINE_PUSH_SELECTION, which sets currentRoute (src/actions/index.js:196-201, src/reducers/globalState.js:291-330).

  3. pushTimelineRange then calls updateTimeline(...), which triggers resetPlayback() when the timeline bounds change (src/actions/index.js:204, src/actions/index.js:163-167).

  4. resetPlayback() sets isBufferingVideo: true (src/timeline/playback.js:64-75).

  5. DriveView renders Media, and Media renders DriveVideo (src/components/DriveView/index.jsx:75-79, src/components/DriveView/Media.jsx:542-547).

  6. DriveVideo.updateVideoSource() sets the stream URL and immediately calls syncVideo() (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

  1. isBufferingVideo starts true (src/initialState.js:17).

  2. While buffering, exit requires readyState >= 4.

  3. If readyState < 4, the else if branch is guaranteed true because it starts with isBufferingVideo || ....

  4. That branch forces newPlaybackRate = 0.

  5. In HLS mode, newPlaybackRate = 0 triggers internalPlayer.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 hasSufficientBuffer and readyState >= 2 to 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 >=2 to >=4).

  • It also removed the explicit hasSufficientBuffer requirement, 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 available
  • 3 (HAVE_FUTURE_DATA): some future data available
  • 4 (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:

  1. Parallel events.json fan-out via Promise.all per segment

    • src/actions/cached.js:309-329
  2. Parallel coords.json fan-out via Promise.all per segment

    • src/actions/cached.js:467-489
  3. Eager thumbnail sprite requests from route.url

    • src/components/Timeline/thumbnails.jsx:39
    • src/components/Timeline/thumbnails.jsx:97

Also, route URLs are normalized to Azure Edge CDN:

  • src/actions/index.js:83

Additional Findings

  1. 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
  2. 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)
  3. Buffering is re-armed by timeline updates

    • Route/timeline transitions call resetPlayback(), which sets isBufferingVideo: true:
    • src/actions/index.js:162-166
    • src/timeline/playback.js:64-71
  4. onBufferEnd and onPlay do not clear buffering state

    • They call onVideoResume, which only clears videoError:
    • src/components/DriveVideo/index.jsx:181-184
    • src/components/DriveVideo/index.jsx:317-319
    • So buffering state exit is effectively controlled by syncVideo() (plus the map view override I wrote about above)
  5. 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 and getVideoState(videoPlayer) on line 112, which can throw if videoPlayer is null.
    • A second bufferVideo(true) can fire at line 115 in the same invocation.
  6. No explicit HLS stall recovery

    • src/components/DriveVideo/index.jsx:122-129
    • onHlsError() handles bufferStalledError and bufferNudgeOnStall by dispatching bufferVideo(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 in syncVideo() 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 >= 4 gate in syncVideo() to eventually clear (the only other bufferVideo(false) path is the map view override in Media.jsx:268-270, which is a UX shortcut, not a stall recovery mechanism).
  7. Redundant bufferVideo(true) dispatches churn state

    • ACTION_BUFFER_VIDEO always rewrites offset and startTime (src/timeline/playback.js:56-62), even when isBufferingVideo is already true.
    • DriveVideo subscribes to both offset and startTime (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.
  8. ReactPlayer playing prop conflicts with manual pause() (plausible amplifier)

    • src/components/DriveVideo/index.jsx:307 passes playing={Boolean(currentRoute && desiredPlaySpeed)} to ReactPlayer.
    • After resetPlayback(), desiredPlaySpeed = 1, so playing evaluates to true.
    • Meanwhile syncVideo() forces internalPlayer.pause() at line 241 when isBufferingVideo is true.
    • ReactPlayer internally manages play/pause state based on its playing prop 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:

  1. "Hard deadlock" in all browsers.
  • Verified: strict gating + forced pause path exists.
  • Not verified here: that paused state always prevents progression to readyState 4.
  1. "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 >= 2 to readyState >= 4.

  • The current state machine keeps forcing rate 0/pause while buffering unless readyState reaches 4.

  • This is consistent with slower startup and more visible lag under marginal loading conditions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions