# All files / src/controller gap-controller.js

90.09% Statements 100/111
76.06% Branches 54/71
100% Functions 6/6
89% Lines 89/100

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236

    1x
    1x
    1x
    1x

    15x
    15x
    15x
    15x
    15x
    15x
    15x
    15x
    15x

    19x
    19x
    19x
    19x

    19x

    19x
    4x
    4x

    1x
    1x
    1x
    1x

    1x
    1x

    4x

    15x

    15x
    3x

    12x
    12x
    12x

    12x

    12x

    12x

    1x
    1x
    1x
    1x

    11x
    11x
    3x
    3x

    8x
    8x

    5x

    8x
    8x

    5x
    5x

    5x
    5x

    1x

    1x

    5x

    1x

    1x
    1x

    7x
    7x

    2x
    2x
    2x

    1x
    1x
    1x

    1x
    1x
    1x
    1x
    1x
    1x
    1x
    1x
    1x

    1x

    3x
    3x
    3x
    3x

    3x
    2x

    2x
    2x

    2x

    1x
    1x

     import { BufferHelper } from '../utils/buffer-helper';
    import { ErrorTypes, ErrorDetails } from '../errors';
    import Event from '../events';
    import { logger } from '../utils/logger';

    export const STALL_MINIMUM_DURATION_MS = 250;
    export const MAX_START_GAP_JUMP = 2.0;
    export const SKIP_BUFFER_HOLE_STEP_SECONDS = 0.1;
    export const SKIP_BUFFER_RANGE_START = 0.05;

    export default class GapController {
      constructor (config, media, fragmentTracker, hls) {
        this.config = config;
        this.media = media;
        this.fragmentTracker = fragmentTracker;
        this.hls = hls;
        this.nudgeRetry = 0;
        this.stallReported = false;
        this.stalled = null;
        this.moved = false;
        this.seeking = false;
      }

      /**
       * Checks if the playhead is stuck within a gap, and if so, attempts to free it.
       * A gap is an unbuffered range between two buffered ranges (or the start and the first buffered range).
       *
       * @param {number} lastCurrentTime Previously read playhead position
       */
      poll (lastCurrentTime) {
        const { config, media, stalled } = this;
        const { currentTime, seeking } = media;
        const seeked = this.seeking && !seeking;
        const beginSeek = !this.seeking && seeking;

        this.seeking = seeking;

        // The playhead is moving, no-op
        if (currentTime !== lastCurrentTime) {
          this.moved = true;
          if (stalled !== null) {
            // The playhead is now moving, but was previously stalled
            Eif (this.stallReported) {
              const stalledDuration = self.performance.now() - stalled;
              logger.warn(`playback not stuck anymore @${currentTime}, after ${Math.round(stalledDuration)}ms`);
              this.stallReported = false;
            }
            this.stalled = null;
            this.nudgeRetry = 0;
          }
          return;
        }

        // Clear stalled state when beginning or finishing seeking so that we don't report stalls coming out of a seek
        Iif (beginSeek || seeked) {
          this.stalled = null;
        }

        // The playhead should not be moving
        if (media.paused || media.ended || media.playbackRate === 0 || !media.buffered.length) {
          return;
        }

        const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
        const isBuffered = bufferInfo.len > 0;
        const nextStart = bufferInfo.nextStart || 0;

        // There is no playable buffer (waiting for buffer append)
        Iif (!isBuffered && !nextStart) {
          return;
        }

        Iif (seeking) {
          // Waiting for seeking in a buffered range to complete
          const hasEnoughBuffer = bufferInfo.len > MAX_START_GAP_JUMP;
          // Next buffered range is too far ahead to jump to while still seeking
          const noBufferGap = !nextStart ||
            (nextStart - currentTime > MAX_START_GAP_JUMP && !this.fragmentTracker.getPartialFragment(currentTime));
          if (hasEnoughBuffer || noBufferGap) {
            return;
          }
          // Reset moved state when seeking to a point in or before a gap
          this.moved = false;
        }

        // Skip start gaps if we haven't played, but the last poll detected the start of a stall
        // The addition poll gives the browser a chance to jump the gap for us
        if (!this.moved && this.stalled) {
          // Jump start gaps within jump threshold
          const startJump = Math.max(nextStart, bufferInfo.start || 0) - currentTime;
          Eif (startJump > 0 && startJump <= MAX_START_GAP_JUMP) {
            this._trySkipBufferHole(null);
            return;
          }
        }

        // Start tracking stall time
        const tnow = self.performance.now();
        if (stalled === null) {
          this.stalled = tnow;
          return;
        }

        const stalledDuration = tnow - stalled;
        if (!seeking && stalledDuration >= STALL_MINIMUM_DURATION_MS) {
          // Report stalling after trying to fix
          this._reportStall(bufferInfo.len);
        }

        const bufferedWithHoles = BufferHelper.bufferInfo(media, currentTime, config.maxBufferHole);
        this._tryFixBufferStall(bufferedWithHoles, stalledDuration);
      }

      /**
       * Detects and attempts to fix known buffer stalling issues.
       * @param bufferInfo - The properties of the current buffer.
       * @param stalledDurationMs - The amount of time Hls.js has been stalling for.
       * @private
       */
      _tryFixBufferStall (bufferInfo, stalledDurationMs) {
        const { config, fragmentTracker, media } = this;
        const currentTime = media.currentTime;

        const partial = fragmentTracker.getPartialFragment(currentTime);
        if (partial) {
          // Try to skip over the buffer hole caused by a partial fragment
          // This method isn't limited by the size of the gap between buffered ranges
          const targetTime = this._trySkipBufferHole(partial);
          // we return here in this case, meaning
          // the branch below only executes when we don't handle a partial fragment
          Iif (targetTime) {
            return;
          }
        }

        // if we haven't had to skip over a buffer hole of a partial fragment
        // we may just have to "nudge" the playlist as the browser decoding/rendering engine
        // needs to cross some sort of threshold covering all source-buffers content
        // to start playing properly.
        if (bufferInfo.len > config.maxBufferHole &&
          stalledDurationMs > config.highBufferWatchdogPeriod * 1000) {
          logger.warn('Trying to nudge playhead over buffer-hole');
          // Try to nudge currentTime over a buffer hole if we've been stalling for the configured amount of seconds
          // We only try to jump the hole if it's under the configured size
          // Reset stalled so to rearm watchdog timer
          this.stalled = null;
          this._tryNudgeBuffer();
        }
      }

      /**
       * Triggers a BUFFER_STALLED_ERROR event, but only once per stall period.
       * @param bufferLen - The playhead distance from the end of the current buffer segment.
       * @private
       */
      _reportStall (bufferLen) {
        const { hls, media, stallReported } = this;
        if (!stallReported) {
          // Report stalled error once
          this.stallReported = true;
          logger.warn(`Playback stalling at @${media.currentTime} due to low buffer`);
          hls.trigger(Event.ERROR, {
            type: ErrorTypes.MEDIA_ERROR,
            details: ErrorDetails.BUFFER_STALLED_ERROR,
            fatal: false,
            buffer: bufferLen
          });
        }
      }

      /**
       * Attempts to fix buffer stalls by jumping over known gaps caused by partial fragments
       * @param partial - The partial fragment found at the current time (where playback is stalling).
       * @private
       */
      _trySkipBufferHole (partial) {
        const { config, hls, media } = this;
        const currentTime = media.currentTime;
        let lastEndTime = 0;
        // Check if currentTime is between unbuffered regions of partial fragments
        for (let i = 0; i < media.buffered.length; i++) {
          const startTime = media.buffered.start(i);
          Eif (currentTime + config.maxBufferHole >= lastEndTime && currentTime < startTime) {
            const targetTime = Math.max(startTime + SKIP_BUFFER_RANGE_START, media.currentTime + SKIP_BUFFER_HOLE_STEP_SECONDS);
            logger.warn(`skipping hole, adjusting currentTime from ${currentTime} to ${targetTime}`);
            this.moved = true;
            this.stalled = null;
            media.currentTime = targetTime;
            Iif (partial) {
              hls.trigger(Event.ERROR, {
                type: ErrorTypes.MEDIA_ERROR,
                details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
                fatal: false,
                reason: `fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`,
                frag: partial
              });
            }
            return targetTime;
          }
          lastEndTime = media.buffered.end(i);
        }
        return 0;
      }

      /**
       * Attempts to fix buffer stalls by advancing the mediaElement's current time by a small amount.
       * @private
       */
      _tryNudgeBuffer () {
        const { config, hls, media } = this;
        const currentTime = media.currentTime;
        const nudgeRetry = (this.nudgeRetry || 0) + 1;
        this.nudgeRetry = nudgeRetry;

        if (nudgeRetry < config.nudgeMaxRetry) {
          const targetTime = currentTime + nudgeRetry * config.nudgeOffset;
          // playback stalled in buffered area ... let's nudge currentTime to try to overcome this
          logger.warn(`Nudging 'currentTime' from ${currentTime} to ${targetTime}`);
          media.currentTime = targetTime;

          hls.trigger(Event.ERROR, {
            type: ErrorTypes.MEDIA_ERROR,
            details: ErrorDetails.BUFFER_NUDGE_ON_STALL,
            fatal: false
          });
        } else {
          logger.error(`Playhead still not moving while enough data buffered @${currentTime} after ${config.nudgeMaxRetry} nudges`);
          hls.trigger(Event.ERROR, {
            type: ErrorTypes.MEDIA_ERROR,
            details: ErrorDetails.BUFFER_STALLED_ERROR,
            fatal: true
          });
        }
      }
    }