Skip to content

Commit b2d7391

Browse files
authored
Defer segment requests when network connection is lost (video-dev#7476)
* Wait to retry fragment request when `navigator.onLine` is false (uses polling for Firefox where "online" event never fires) Fixes video-dev#7471 * Only treat no-alternate segment request failures as gaps in live playlists (video-dev#7464, video-dev#7410) * Retry once per seeking out of current fragment range (even when offline) * Do not exhaust retries in tick loop while seeking * Only schedule immediate tick on seeking when buffer is empty and state is idle (video-dev#7472)
1 parent 4ebd6d2 commit b2d7391

File tree

5 files changed

+65
-45
lines changed

5 files changed

+65
-45
lines changed

api-extractor/report/hls.js.api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,8 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP
392392
// (undocumented)
393393
protected checkLiveUpdate(details: LevelDetails): void;
394394
// (undocumented)
395+
protected checkRetryDate(): void;
396+
// (undocumented)
395397
protected clearTrackerIfNeeded(frag: Fragment): void;
396398
// (undocumented)
397399
protected config: HlsConfig;
@@ -528,8 +530,6 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP
528530
// (undocumented)
529531
protected resetLoadingState(): void;
530532
// (undocumented)
531-
protected resetStartWhenNotLoaded(level: Level | null): void;
532-
// (undocumented)
533533
protected resetTransmuxer(): void;
534534
// (undocumented)
535535
protected resetWhenMissingContext(chunkMeta: ChunkMetadata | Fragment): void;

src/controller/audio-stream-controller.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -255,15 +255,7 @@ class AudioStreamController
255255
break;
256256
}
257257
case State.FRAG_LOADING_WAITING_RETRY: {
258-
const now = performance.now();
259-
const retryDate = this.retryDate;
260-
// if current time is gt than retryDate, or if media seeking let's switch to IDLE state to retry loading
261-
if (!retryDate || now >= retryDate || this.media?.seeking) {
262-
const { levels, trackId } = this;
263-
this.log('RetryDate reached, switch back to IDLE state');
264-
this.resetStartWhenNotLoaded(levels?.[trackId] || null);
265-
this.state = State.IDLE;
266-
}
258+
this.checkRetryDate();
267259
break;
268260
}
269261
case State.WAITING_INIT_PTS: {

src/controller/base-stream-controller.ts

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import {
2424
getAesModeFromFullSegmentMethod,
2525
isFullSegmentEncryption,
2626
} from '../utils/encryption-methods-util';
27-
import { getRetryDelay } from '../utils/error-helper';
27+
import { getRetryDelay, offlineHttpStatus } from '../utils/error-helper';
28+
import {
29+
addEventListener,
30+
removeEventListener,
31+
} from '../utils/event-listener-helper';
2832
import {
2933
findPart,
3034
getFragmentWithSN,
@@ -284,10 +288,8 @@ export default class BaseStreamController
284288
data: MediaAttachedData,
285289
) {
286290
const media = (this.media = this.mediaBuffer = data.media);
287-
media.removeEventListener('seeking', this.onMediaSeeking);
288-
media.removeEventListener('ended', this.onMediaEnded);
289-
media.addEventListener('seeking', this.onMediaSeeking);
290-
media.addEventListener('ended', this.onMediaEnded);
291+
addEventListener(media, 'seeking', this.onMediaSeeking);
292+
addEventListener(media, 'ended', this.onMediaEnded);
291293
const config = this.config;
292294
if (this.levels && config.autoStartLoad && this.state === State.STOPPED) {
293295
this.startLoad(config.startPosition);
@@ -309,8 +311,8 @@ export default class BaseStreamController
309311
}
310312

311313
// remove video listeners
312-
media.removeEventListener('seeking', this.onMediaSeeking);
313-
media.removeEventListener('ended', this.onMediaEnded);
314+
removeEventListener(media, 'seeking', this.onMediaSeeking);
315+
removeEventListener(media, 'ended', this.onMediaEnded);
314316

315317
if (this.keyLoader && !transferringMedia) {
316318
this.keyLoader.detach();
@@ -424,8 +426,10 @@ export default class BaseStreamController
424426
}
425427
}
426428

427-
// Async tick to speed up processing
428-
this.tickImmediate();
429+
if (noFowardBuffer && this.state === State.IDLE) {
430+
// Async tick to speed up processing
431+
this.tickImmediate();
432+
}
429433
};
430434

431435
protected onMediaEnded = () => {
@@ -1883,38 +1887,49 @@ export default class BaseStreamController
18831887
}
18841888
// keep retrying until the limit will be reached
18851889
const errorAction = data.errorAction;
1886-
const { action, flags, retryCount = 0, retryConfig } = errorAction || {};
1887-
const couldRetry = !!errorAction && !!retryConfig;
1890+
if (!errorAction) {
1891+
this.state = State.ERROR;
1892+
return;
1893+
}
1894+
const { action, flags, retryCount = 0, retryConfig } = errorAction;
1895+
const couldRetry = !!retryConfig;
18881896
const retry = couldRetry && action === NetworkErrorAction.RetryRequest;
18891897
const noAlternate =
18901898
couldRetry &&
18911899
!errorAction.resolved &&
18921900
flags === ErrorActionFlags.MoveAllAlternatesMatchingHost;
1893-
const httpStatus = data.response?.code || 0;
1901+
const live = this.hls.latestLevelDetails?.live;
18941902
if (
18951903
!retry &&
18961904
noAlternate &&
18971905
isMediaFragment(frag) &&
18981906
!frag.endList &&
1899-
httpStatus !== 0
1907+
live
19001908
) {
19011909
this.resetFragmentErrors(filterType);
19021910
this.treatAsGap(frag);
19031911
errorAction.resolved = true;
19041912
} else if ((retry || noAlternate) && retryCount < retryConfig.maxNumRetry) {
1905-
this.resetStartWhenNotLoaded(this.levelLastLoaded);
1913+
const offlineStatus = offlineHttpStatus(data.response?.code);
19061914
const delay = getRetryDelay(retryConfig, retryCount);
1915+
this.resetStartWhenNotLoaded();
1916+
this.retryDate = self.performance.now() + delay;
1917+
this.state = State.FRAG_LOADING_WAITING_RETRY;
1918+
errorAction.resolved = true;
1919+
if (offlineStatus) {
1920+
this.log(`Waiting for connection (offline)`);
1921+
this.retryDate = Infinity;
1922+
data.reason = 'offline';
1923+
return;
1924+
}
19071925
this.warn(
19081926
`Fragment ${frag.sn} of ${filterType} ${frag.level} errored with ${
19091927
data.details
19101928
}, retrying loading ${retryCount + 1}/${
19111929
retryConfig.maxNumRetry
19121930
} in ${delay}ms`,
19131931
);
1914-
errorAction.resolved = true;
1915-
this.retryDate = self.performance.now() + delay;
1916-
this.state = State.FRAG_LOADING_WAITING_RETRY;
1917-
} else if (retryConfig && errorAction) {
1932+
} else if (retryConfig) {
19181933
this.resetFragmentErrors(filterType);
19191934
if (retryCount < retryConfig.maxNumRetry) {
19201935
// Network retry is skipped when level switch is preferred
@@ -1939,6 +1954,24 @@ export default class BaseStreamController
19391954
this.tickImmediate();
19401955
}
19411956

1957+
protected checkRetryDate() {
1958+
const now = self.performance.now();
1959+
const retryDate = this.retryDate;
1960+
// if current time is gt than retryDate, or if media seeking let's switch to IDLE state to retry loading
1961+
const waitingForConnection = retryDate === Infinity;
1962+
if (
1963+
!retryDate ||
1964+
now >= retryDate ||
1965+
(waitingForConnection && !offlineHttpStatus(0))
1966+
) {
1967+
if (waitingForConnection) {
1968+
this.log(`Connection restored (online)`);
1969+
}
1970+
this.resetStartWhenNotLoaded();
1971+
this.state = State.IDLE;
1972+
}
1973+
}
1974+
19421975
protected reduceLengthAndFlushBuffer(data: ErrorData): boolean {
19431976
// if in appending state
19441977
if (this.state === State.PARSING || this.state === State.PARSED) {
@@ -2018,11 +2051,12 @@ export default class BaseStreamController
20182051
}
20192052
}
20202053

2021-
protected resetStartWhenNotLoaded(level: Level | null): void {
2054+
private resetStartWhenNotLoaded() {
20222055
// if loadedmetadata is not set, it means that first frag request failed
20232056
// in that case, reset startFragRequested flag
20242057
if (!this.hls.hasEnoughToStart) {
20252058
this.startFragRequested = false;
2059+
const level = this.levelLastLoaded;
20262060
const details = level ? level.details : null;
20272061
if (details?.live) {
20282062
// Update the start position and return to IDLE to recover live start
@@ -2041,7 +2075,7 @@ export default class BaseStreamController
20412075
`Loading context changed while buffering sn ${chunkMeta.sn} of ${this.playlistLabel()} ${chunkMeta.level === -1 ? '<removed>' : chunkMeta.level}. This chunk will not be buffered.`,
20422076
);
20432077
this.removeUnbufferedFrags();
2044-
this.resetStartWhenNotLoaded(this.levelLastLoaded);
2078+
this.resetStartWhenNotLoaded();
20452079
this.resetLoadingState();
20462080
}
20472081

@@ -2176,7 +2210,7 @@ export default class BaseStreamController
21762210
this.transmuxer.destroy();
21772211
this.transmuxer = null;
21782212
}
2179-
this.resetStartWhenNotLoaded(this.levelLastLoaded);
2213+
this.resetStartWhenNotLoaded();
21802214
this.resetLoadingState();
21812215
}
21822216
}

src/controller/stream-controller.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -215,17 +215,7 @@ export default class StreamController
215215
break;
216216
}
217217
case State.FRAG_LOADING_WAITING_RETRY:
218-
{
219-
const now = self.performance.now();
220-
const retryDate = this.retryDate;
221-
// if current time is gt than retryDate, or if media seeking let's switch to IDLE state to retry loading
222-
if (!retryDate || now >= retryDate || this.media?.seeking) {
223-
const { levels, level } = this;
224-
const currentLevel = levels?.[level];
225-
this.resetStartWhenNotLoaded(currentLevel || null);
226-
this.state = State.IDLE;
227-
}
228-
}
218+
this.checkRetryDate();
229219
break;
230220
default:
231221
break;

src/utils/error-helper.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,14 @@ export function shouldRetry(
7171
: retry;
7272
}
7373

74-
export function retryForHttpStatus(httpStatus: number | undefined) {
74+
export function retryForHttpStatus(httpStatus: number | undefined): boolean {
7575
// Do not retry on status 4xx, status 0 (CORS error), or undefined (decrypt/gap/parse error)
7676
return (
77-
(httpStatus === 0 && navigator.onLine === false) ||
77+
offlineHttpStatus(httpStatus) ||
7878
(!!httpStatus && (httpStatus < 400 || httpStatus > 499))
7979
);
8080
}
81+
82+
export function offlineHttpStatus(httpStatus: number | undefined): boolean {
83+
return httpStatus === 0 && navigator.onLine === false;
84+
}

0 commit comments

Comments
 (0)