@zenvor/hls.js 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +472 -0
- package/dist/hls-demo.js +26995 -0
- package/dist/hls-demo.js.map +1 -0
- package/dist/hls.d.mts +4204 -0
- package/dist/hls.d.ts +4204 -0
- package/dist/hls.js +40050 -0
- package/dist/hls.js.d.ts +4204 -0
- package/dist/hls.js.map +1 -0
- package/dist/hls.light.js +27145 -0
- package/dist/hls.light.js.map +1 -0
- package/dist/hls.light.min.js +2 -0
- package/dist/hls.light.min.js.map +1 -0
- package/dist/hls.light.mjs +26392 -0
- package/dist/hls.light.mjs.map +1 -0
- package/dist/hls.min.js +2 -0
- package/dist/hls.min.js.map +1 -0
- package/dist/hls.mjs +38956 -0
- package/dist/hls.mjs.map +1 -0
- package/dist/hls.worker.js +2 -0
- package/dist/hls.worker.js.map +1 -0
- package/package.json +143 -0
- package/src/config.ts +794 -0
- package/src/controller/abr-controller.ts +1019 -0
- package/src/controller/algo-data-controller.ts +794 -0
- package/src/controller/audio-stream-controller.ts +1099 -0
- package/src/controller/audio-track-controller.ts +454 -0
- package/src/controller/base-playlist-controller.ts +438 -0
- package/src/controller/base-stream-controller.ts +2526 -0
- package/src/controller/buffer-controller.ts +2015 -0
- package/src/controller/buffer-operation-queue.ts +159 -0
- package/src/controller/cap-level-controller.ts +367 -0
- package/src/controller/cmcd-controller.ts +422 -0
- package/src/controller/content-steering-controller.ts +622 -0
- package/src/controller/eme-controller.ts +1617 -0
- package/src/controller/error-controller.ts +627 -0
- package/src/controller/fps-controller.ts +146 -0
- package/src/controller/fragment-finders.ts +256 -0
- package/src/controller/fragment-tracker.ts +567 -0
- package/src/controller/gap-controller.ts +719 -0
- package/src/controller/id3-track-controller.ts +488 -0
- package/src/controller/interstitial-player.ts +302 -0
- package/src/controller/interstitials-controller.ts +2895 -0
- package/src/controller/interstitials-schedule.ts +698 -0
- package/src/controller/latency-controller.ts +294 -0
- package/src/controller/level-controller.ts +776 -0
- package/src/controller/stream-controller.ts +1597 -0
- package/src/controller/subtitle-stream-controller.ts +508 -0
- package/src/controller/subtitle-track-controller.ts +617 -0
- package/src/controller/timeline-controller.ts +677 -0
- package/src/crypt/aes-crypto.ts +36 -0
- package/src/crypt/aes-decryptor.ts +339 -0
- package/src/crypt/decrypter-aes-mode.ts +4 -0
- package/src/crypt/decrypter.ts +225 -0
- package/src/crypt/fast-aes-key.ts +39 -0
- package/src/define-plugin.d.ts +17 -0
- package/src/demux/audio/aacdemuxer.ts +126 -0
- package/src/demux/audio/ac3-demuxer.ts +170 -0
- package/src/demux/audio/adts.ts +249 -0
- package/src/demux/audio/base-audio-demuxer.ts +205 -0
- package/src/demux/audio/dolby.ts +21 -0
- package/src/demux/audio/mp3demuxer.ts +85 -0
- package/src/demux/audio/mpegaudio.ts +177 -0
- package/src/demux/chunk-cache.ts +42 -0
- package/src/demux/dummy-demuxed-track.ts +13 -0
- package/src/demux/inject-worker.ts +75 -0
- package/src/demux/mp4demuxer.ts +234 -0
- package/src/demux/sample-aes.ts +198 -0
- package/src/demux/transmuxer-interface.ts +449 -0
- package/src/demux/transmuxer-worker.ts +221 -0
- package/src/demux/transmuxer.ts +560 -0
- package/src/demux/tsdemuxer.ts +1256 -0
- package/src/demux/video/avc-video-parser.ts +401 -0
- package/src/demux/video/base-video-parser.ts +198 -0
- package/src/demux/video/exp-golomb.ts +153 -0
- package/src/demux/video/hevc-video-parser.ts +736 -0
- package/src/empty-es.js +5 -0
- package/src/empty.js +3 -0
- package/src/errors.ts +107 -0
- package/src/events.ts +548 -0
- package/src/exports-default.ts +3 -0
- package/src/exports-named.ts +81 -0
- package/src/hls.ts +1613 -0
- package/src/is-supported.ts +54 -0
- package/src/loader/date-range.ts +207 -0
- package/src/loader/fragment-loader.ts +403 -0
- package/src/loader/fragment.ts +487 -0
- package/src/loader/interstitial-asset-list.ts +162 -0
- package/src/loader/interstitial-event.ts +337 -0
- package/src/loader/key-loader.ts +439 -0
- package/src/loader/level-details.ts +203 -0
- package/src/loader/level-key.ts +259 -0
- package/src/loader/load-stats.ts +17 -0
- package/src/loader/m3u8-parser.ts +1072 -0
- package/src/loader/playlist-loader.ts +839 -0
- package/src/polyfills/number.ts +15 -0
- package/src/remux/aac-helper.ts +81 -0
- package/src/remux/mp4-generator.ts +1380 -0
- package/src/remux/mp4-remuxer.ts +1261 -0
- package/src/remux/passthrough-remuxer.ts +434 -0
- package/src/task-loop.ts +130 -0
- package/src/types/algo.ts +44 -0
- package/src/types/buffer.ts +105 -0
- package/src/types/component-api.ts +20 -0
- package/src/types/demuxer.ts +208 -0
- package/src/types/events.ts +574 -0
- package/src/types/fragment-tracker.ts +23 -0
- package/src/types/level.ts +268 -0
- package/src/types/loader.ts +198 -0
- package/src/types/media-playlist.ts +92 -0
- package/src/types/network-details.ts +3 -0
- package/src/types/remuxer.ts +104 -0
- package/src/types/track.ts +12 -0
- package/src/types/transmuxer.ts +46 -0
- package/src/types/tuples.ts +6 -0
- package/src/types/vtt.ts +11 -0
- package/src/utils/arrays.ts +22 -0
- package/src/utils/attr-list.ts +192 -0
- package/src/utils/binary-search.ts +46 -0
- package/src/utils/buffer-helper.ts +173 -0
- package/src/utils/cea-608-parser.ts +1413 -0
- package/src/utils/chunker.ts +41 -0
- package/src/utils/codecs.ts +314 -0
- package/src/utils/cues.ts +96 -0
- package/src/utils/discontinuities.ts +174 -0
- package/src/utils/encryption-methods-util.ts +21 -0
- package/src/utils/error-helper.ts +95 -0
- package/src/utils/event-listener-helper.ts +16 -0
- package/src/utils/ewma-bandwidth-estimator.ts +97 -0
- package/src/utils/ewma.ts +43 -0
- package/src/utils/fetch-loader.ts +331 -0
- package/src/utils/global.ts +2 -0
- package/src/utils/hash.ts +10 -0
- package/src/utils/hdr.ts +67 -0
- package/src/utils/hex.ts +32 -0
- package/src/utils/imsc1-ttml-parser.ts +261 -0
- package/src/utils/keysystem-util.ts +45 -0
- package/src/utils/level-helper.ts +629 -0
- package/src/utils/logger.ts +120 -0
- package/src/utils/media-option-attributes.ts +49 -0
- package/src/utils/mediacapabilities-helper.ts +301 -0
- package/src/utils/mediakeys-helper.ts +210 -0
- package/src/utils/mediasource-helper.ts +37 -0
- package/src/utils/mp4-tools.ts +1473 -0
- package/src/utils/number.ts +3 -0
- package/src/utils/numeric-encoding-utils.ts +26 -0
- package/src/utils/output-filter.ts +46 -0
- package/src/utils/rendition-helper.ts +505 -0
- package/src/utils/safe-json-stringify.ts +22 -0
- package/src/utils/texttrack-utils.ts +164 -0
- package/src/utils/time-ranges.ts +17 -0
- package/src/utils/timescale-conversion.ts +46 -0
- package/src/utils/utf8-utils.ts +18 -0
- package/src/utils/variable-substitution.ts +105 -0
- package/src/utils/vttcue.ts +384 -0
- package/src/utils/vttparser.ts +497 -0
- package/src/utils/webvtt-parser.ts +166 -0
- package/src/utils/xhr-loader.ts +337 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,2526 @@
|
|
|
1
|
+
import { ErrorActionFlags, NetworkErrorAction } from './error-controller';
|
|
2
|
+
import {
|
|
3
|
+
findFragmentByPDT,
|
|
4
|
+
findFragmentByPTS,
|
|
5
|
+
findNearestWithCC,
|
|
6
|
+
} from './fragment-finders';
|
|
7
|
+
import { FragmentState } from './fragment-tracker';
|
|
8
|
+
import Decrypter from '../crypt/decrypter';
|
|
9
|
+
import { ErrorDetails, ErrorTypes } from '../errors';
|
|
10
|
+
import { Events } from '../events';
|
|
11
|
+
import {
|
|
12
|
+
type Fragment,
|
|
13
|
+
isMediaFragment,
|
|
14
|
+
type MediaFragment,
|
|
15
|
+
type Part,
|
|
16
|
+
} from '../loader/fragment';
|
|
17
|
+
import FragmentLoader from '../loader/fragment-loader';
|
|
18
|
+
import TaskLoop from '../task-loop';
|
|
19
|
+
import { PlaylistLevelType } from '../types/loader';
|
|
20
|
+
import { ChunkMetadata } from '../types/transmuxer';
|
|
21
|
+
import { BufferHelper } from '../utils/buffer-helper';
|
|
22
|
+
import { alignStream } from '../utils/discontinuities';
|
|
23
|
+
import {
|
|
24
|
+
getAesModeFromFullSegmentMethod,
|
|
25
|
+
isFullSegmentEncryption,
|
|
26
|
+
} from '../utils/encryption-methods-util';
|
|
27
|
+
import {
|
|
28
|
+
getRetryDelay,
|
|
29
|
+
isUnusableKeyError,
|
|
30
|
+
offlineHttpStatus,
|
|
31
|
+
} from '../utils/error-helper';
|
|
32
|
+
import {
|
|
33
|
+
addEventListener,
|
|
34
|
+
removeEventListener,
|
|
35
|
+
} from '../utils/event-listener-helper';
|
|
36
|
+
import {
|
|
37
|
+
findPart,
|
|
38
|
+
getFragmentWithSN,
|
|
39
|
+
getPartWith,
|
|
40
|
+
updateFragPTSDTS,
|
|
41
|
+
} from '../utils/level-helper';
|
|
42
|
+
import { estimatedAudioBitrate } from '../utils/mediacapabilities-helper';
|
|
43
|
+
import { appendUint8Array } from '../utils/mp4-tools';
|
|
44
|
+
import TimeRanges from '../utils/time-ranges';
|
|
45
|
+
import type { FragmentTracker } from './fragment-tracker';
|
|
46
|
+
import type { HlsConfig } from '../config';
|
|
47
|
+
import type TransmuxerInterface from '../demux/transmuxer-interface';
|
|
48
|
+
import type Hls from '../hls';
|
|
49
|
+
import type {
|
|
50
|
+
FragmentLoadProgressCallback,
|
|
51
|
+
LoadError,
|
|
52
|
+
} from '../loader/fragment-loader';
|
|
53
|
+
import type KeyLoader from '../loader/key-loader';
|
|
54
|
+
import type { LevelDetails } from '../loader/level-details';
|
|
55
|
+
import type { SourceBufferName } from '../types/buffer';
|
|
56
|
+
import type { NetworkComponentAPI } from '../types/component-api';
|
|
57
|
+
import type {
|
|
58
|
+
BufferAppendingData,
|
|
59
|
+
BufferFlushingData,
|
|
60
|
+
ErrorData,
|
|
61
|
+
FragLoadedData,
|
|
62
|
+
KeyLoadedData,
|
|
63
|
+
ManifestLoadedData,
|
|
64
|
+
MediaAttachedData,
|
|
65
|
+
MediaDetachingData,
|
|
66
|
+
PartsLoadedData,
|
|
67
|
+
} from '../types/events';
|
|
68
|
+
import type { Level } from '../types/level';
|
|
69
|
+
import type { InitSegmentData, RemuxedTrack } from '../types/remuxer';
|
|
70
|
+
import type { Bufferable, BufferInfo } from '../utils/buffer-helper';
|
|
71
|
+
import type { TimestampOffset } from '../utils/timescale-conversion';
|
|
72
|
+
|
|
73
|
+
type ResolveFragLoaded = (FragLoadedEndData) => void;
|
|
74
|
+
type RejectFragLoaded = (LoadError) => void;
|
|
75
|
+
|
|
76
|
+
export const State = {
|
|
77
|
+
STOPPED: 'STOPPED',
|
|
78
|
+
IDLE: 'IDLE',
|
|
79
|
+
KEY_LOADING: 'KEY_LOADING',
|
|
80
|
+
FRAG_LOADING: 'FRAG_LOADING',
|
|
81
|
+
FRAG_LOADING_WAITING_RETRY: 'FRAG_LOADING_WAITING_RETRY',
|
|
82
|
+
WAITING_TRACK: 'WAITING_TRACK',
|
|
83
|
+
PARSING: 'PARSING',
|
|
84
|
+
PARSED: 'PARSED',
|
|
85
|
+
ENDED: 'ENDED',
|
|
86
|
+
ERROR: 'ERROR',
|
|
87
|
+
WAITING_INIT_PTS: 'WAITING_INIT_PTS',
|
|
88
|
+
WAITING_LEVEL: 'WAITING_LEVEL',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type InFlightData = {
|
|
92
|
+
frag: Fragment | null;
|
|
93
|
+
state: (typeof State)[keyof typeof State];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export default class BaseStreamController
|
|
97
|
+
extends TaskLoop
|
|
98
|
+
implements NetworkComponentAPI
|
|
99
|
+
{
|
|
100
|
+
protected hls: Hls;
|
|
101
|
+
|
|
102
|
+
protected fragPrevious: MediaFragment | null = null;
|
|
103
|
+
protected fragCurrent: Fragment | null = null;
|
|
104
|
+
protected fragPlaying: Fragment | null = null;
|
|
105
|
+
protected fragmentTracker: FragmentTracker;
|
|
106
|
+
protected transmuxer: TransmuxerInterface | null = null;
|
|
107
|
+
protected _state: (typeof State)[keyof typeof State] = State.STOPPED;
|
|
108
|
+
protected playlistType: PlaylistLevelType;
|
|
109
|
+
protected media: HTMLMediaElement | null = null;
|
|
110
|
+
protected mediaBuffer: Bufferable | null = null;
|
|
111
|
+
protected config: HlsConfig;
|
|
112
|
+
protected bitrateTest: boolean = false;
|
|
113
|
+
protected lastCurrentTime: number = 0;
|
|
114
|
+
protected nextLoadPosition: number = 0;
|
|
115
|
+
protected startPosition: number = 0;
|
|
116
|
+
protected startTimeOffset: number | null = null;
|
|
117
|
+
protected retryDate: number = 0;
|
|
118
|
+
protected levels: Array<Level> | null = null;
|
|
119
|
+
protected fragmentLoader: FragmentLoader;
|
|
120
|
+
protected keyLoader: KeyLoader;
|
|
121
|
+
protected levelLastLoaded: Level | null = null;
|
|
122
|
+
protected startFragRequested: boolean = false;
|
|
123
|
+
protected decrypter: Decrypter;
|
|
124
|
+
protected initPTS: TimestampOffset[] = [];
|
|
125
|
+
protected buffering: boolean = true;
|
|
126
|
+
protected loadingParts: boolean = false;
|
|
127
|
+
private loopSn?: string | number;
|
|
128
|
+
|
|
129
|
+
constructor(
|
|
130
|
+
hls: Hls,
|
|
131
|
+
fragmentTracker: FragmentTracker,
|
|
132
|
+
keyLoader: KeyLoader,
|
|
133
|
+
logPrefix: string,
|
|
134
|
+
playlistType: PlaylistLevelType,
|
|
135
|
+
) {
|
|
136
|
+
super(logPrefix, hls.logger);
|
|
137
|
+
this.playlistType = playlistType;
|
|
138
|
+
this.hls = hls;
|
|
139
|
+
this.fragmentLoader = new FragmentLoader(hls.config);
|
|
140
|
+
this.keyLoader = keyLoader;
|
|
141
|
+
this.fragmentTracker = fragmentTracker;
|
|
142
|
+
this.config = hls.config;
|
|
143
|
+
this.decrypter = new Decrypter(hls.config);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
protected registerListeners() {
|
|
147
|
+
const { hls } = this;
|
|
148
|
+
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
|
|
149
|
+
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
|
|
150
|
+
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
|
|
151
|
+
hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
|
|
152
|
+
hls.on(Events.ERROR, this.onError, this);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
protected unregisterListeners() {
|
|
156
|
+
const { hls } = this;
|
|
157
|
+
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
|
|
158
|
+
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
|
|
159
|
+
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
|
|
160
|
+
hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
|
|
161
|
+
hls.off(Events.ERROR, this.onError, this);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
protected doTick() {
|
|
165
|
+
this.onTickEnd();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
protected onTickEnd() {}
|
|
169
|
+
|
|
170
|
+
public startLoad(startPosition: number): void {}
|
|
171
|
+
|
|
172
|
+
public stopLoad() {
|
|
173
|
+
if (this.state === State.STOPPED) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.fragmentLoader.abort();
|
|
177
|
+
this.keyLoader.abort(this.playlistType);
|
|
178
|
+
const frag = this.fragCurrent;
|
|
179
|
+
if (frag?.loader) {
|
|
180
|
+
frag.abortRequests();
|
|
181
|
+
this.fragmentTracker.removeFragment(frag);
|
|
182
|
+
}
|
|
183
|
+
this.resetTransmuxer();
|
|
184
|
+
this.fragCurrent = null;
|
|
185
|
+
this.fragPrevious = null;
|
|
186
|
+
this.clearInterval();
|
|
187
|
+
this.clearNextTick();
|
|
188
|
+
this.state = State.STOPPED;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
public get startPositionValue(): number {
|
|
192
|
+
const { nextLoadPosition, startPosition } = this;
|
|
193
|
+
if (startPosition === -1 && nextLoadPosition) {
|
|
194
|
+
return nextLoadPosition;
|
|
195
|
+
}
|
|
196
|
+
return startPosition;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
public get bufferingEnabled(): boolean {
|
|
200
|
+
return this.buffering;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get backtrack fragment. Returns null in base class.
|
|
205
|
+
* Override in stream-controller to return actual backtrack fragment.
|
|
206
|
+
*/
|
|
207
|
+
protected get backtrackFragment(): Fragment | undefined {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Set backtrack fragment. No-op in base class.
|
|
213
|
+
* Override in stream-controller to set actual backtrack fragment.
|
|
214
|
+
*/
|
|
215
|
+
protected set backtrackFragment(_value: Fragment | undefined) {}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get could backtrack flag. Returns false in base class.
|
|
219
|
+
* Override in stream-controller to return actual value.
|
|
220
|
+
*/
|
|
221
|
+
protected get couldBacktrack(): boolean {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Set could backtrack flag. No-op in base class.
|
|
227
|
+
* Override in stream-controller to set actual value.
|
|
228
|
+
*/
|
|
229
|
+
protected set couldBacktrack(_value: boolean) {}
|
|
230
|
+
|
|
231
|
+
public pauseBuffering() {
|
|
232
|
+
this.buffering = false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
public resumeBuffering() {
|
|
236
|
+
this.buffering = true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
public get inFlightFrag(): InFlightData {
|
|
240
|
+
return { frag: this.fragCurrent, state: this.state };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
protected _streamEnded(
|
|
244
|
+
bufferInfo: BufferInfo,
|
|
245
|
+
levelDetails: LevelDetails,
|
|
246
|
+
): boolean {
|
|
247
|
+
// Stream is never "ended" when playlist is live or media is detached
|
|
248
|
+
if (levelDetails.live || !this.media) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
// Stream is not "ended" when nothing is buffered past the start
|
|
252
|
+
const bufferEnd = bufferInfo.end || 0;
|
|
253
|
+
const timelineStart = this.config.timelineOffset || 0;
|
|
254
|
+
if (bufferEnd <= timelineStart) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
// Stream is not "ended" when there is a second buffered range starting before the end of the playlist
|
|
258
|
+
const bufferedRanges = bufferInfo.buffered;
|
|
259
|
+
if (
|
|
260
|
+
this.config.maxBufferHole &&
|
|
261
|
+
bufferedRanges &&
|
|
262
|
+
bufferedRanges.length > 1
|
|
263
|
+
) {
|
|
264
|
+
// make sure bufferInfo accounts for any gaps
|
|
265
|
+
bufferInfo = BufferHelper.bufferedInfo(
|
|
266
|
+
bufferedRanges,
|
|
267
|
+
bufferInfo.start,
|
|
268
|
+
0,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const nextStart = bufferInfo.nextStart;
|
|
272
|
+
const hasSecondBufferedRange =
|
|
273
|
+
nextStart && nextStart > timelineStart && nextStart < levelDetails.edge;
|
|
274
|
+
if (hasSecondBufferedRange) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
// Playhead is in unbuffered region. Marking EoS now could result in Safari failing to dispatch "ended" event following seek on start.
|
|
278
|
+
if (this.media.currentTime < bufferInfo.start) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
const partList = levelDetails.partList;
|
|
282
|
+
// Since the last part isn't guaranteed to correspond to the last playlist segment for Low-Latency HLS,
|
|
283
|
+
// check instead if the last part is buffered.
|
|
284
|
+
if (partList?.length) {
|
|
285
|
+
const lastPart = partList[partList.length - 1];
|
|
286
|
+
|
|
287
|
+
// Checking the midpoint of the part for potential margin of error and related issues.
|
|
288
|
+
// NOTE: Technically I believe parts could yield content that is < the computed duration (including potential a duration of 0)
|
|
289
|
+
// and still be spec-compliant, so there may still be edge cases here. Likewise, there could be issues in end of stream
|
|
290
|
+
// part mismatches for independent audio and video playlists/segments.
|
|
291
|
+
const lastPartBuffered = BufferHelper.isBuffered(
|
|
292
|
+
this.media,
|
|
293
|
+
lastPart.start + lastPart.duration / 2,
|
|
294
|
+
);
|
|
295
|
+
return lastPartBuffered;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const playlistType =
|
|
299
|
+
levelDetails.fragments[levelDetails.fragments.length - 1].type;
|
|
300
|
+
return this.fragmentTracker.isEndListAppended(playlistType);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
public getLevelDetails(): LevelDetails | undefined {
|
|
304
|
+
if (this.levels && this.levelLastLoaded !== null) {
|
|
305
|
+
return this.levelLastLoaded.details;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
protected get timelineOffset(): number {
|
|
310
|
+
const configuredTimelineOffset = this.config.timelineOffset;
|
|
311
|
+
if (configuredTimelineOffset) {
|
|
312
|
+
return (
|
|
313
|
+
this.getLevelDetails()?.appliedTimelineOffset ||
|
|
314
|
+
configuredTimelineOffset
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
protected onMediaAttached(
|
|
321
|
+
event: Events.MEDIA_ATTACHED,
|
|
322
|
+
data: MediaAttachedData,
|
|
323
|
+
) {
|
|
324
|
+
const media = (this.media = this.mediaBuffer = data.media);
|
|
325
|
+
addEventListener(media, 'seeking', this.onMediaSeeking);
|
|
326
|
+
addEventListener(media, 'ended', this.onMediaEnded);
|
|
327
|
+
const config = this.config;
|
|
328
|
+
if (this.levels && config.autoStartLoad && this.state === State.STOPPED) {
|
|
329
|
+
this.startLoad(config.startPosition);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
protected onMediaDetaching(
|
|
334
|
+
event: Events.MEDIA_DETACHING,
|
|
335
|
+
data: MediaDetachingData,
|
|
336
|
+
) {
|
|
337
|
+
this.fragPlaying = null;
|
|
338
|
+
const transferringMedia = !!data.transferMedia;
|
|
339
|
+
const media = this.media;
|
|
340
|
+
if (media === null) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (media.ended) {
|
|
344
|
+
this.log('MSE detaching and video ended, reset startPosition');
|
|
345
|
+
this.startPosition = this.lastCurrentTime = 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// remove video listeners
|
|
349
|
+
removeEventListener(media, 'seeking', this.onMediaSeeking);
|
|
350
|
+
removeEventListener(media, 'ended', this.onMediaEnded);
|
|
351
|
+
|
|
352
|
+
if (this.keyLoader && !transferringMedia) {
|
|
353
|
+
this.keyLoader.detach();
|
|
354
|
+
}
|
|
355
|
+
this.media = this.mediaBuffer = null;
|
|
356
|
+
this.loopSn = undefined;
|
|
357
|
+
if (transferringMedia) {
|
|
358
|
+
this.resetLoadingState();
|
|
359
|
+
this.resetTransmuxer();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
this.loadingParts = false;
|
|
363
|
+
this.fragmentTracker.removeAllFragments();
|
|
364
|
+
this.stopLoad();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
protected onManifestLoading() {
|
|
368
|
+
this.initPTS = [];
|
|
369
|
+
this.fragPlaying =
|
|
370
|
+
this.levels =
|
|
371
|
+
this.levelLastLoaded =
|
|
372
|
+
this.fragCurrent =
|
|
373
|
+
null;
|
|
374
|
+
this.lastCurrentTime = this.startPosition = 0;
|
|
375
|
+
this.startFragRequested = false;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
protected onError(event: Events.ERROR, data: ErrorData) {}
|
|
379
|
+
|
|
380
|
+
protected onMediaSeeking = () => {
|
|
381
|
+
const { config, fragCurrent, media, mediaBuffer, state } = this;
|
|
382
|
+
const currentTime: number = media ? media.currentTime : 0;
|
|
383
|
+
const bufferInfo = BufferHelper.bufferInfo(
|
|
384
|
+
mediaBuffer ? mediaBuffer : media,
|
|
385
|
+
currentTime,
|
|
386
|
+
config.maxBufferHole,
|
|
387
|
+
);
|
|
388
|
+
const noFowardBuffer = !bufferInfo.len;
|
|
389
|
+
|
|
390
|
+
this.log(
|
|
391
|
+
`Media seeking to ${
|
|
392
|
+
Number.isFinite(currentTime) ? currentTime.toFixed(3) : currentTime
|
|
393
|
+
}, state: ${state}, ${noFowardBuffer ? 'out of' : 'in'} buffer`,
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
if (this.state === State.ENDED) {
|
|
397
|
+
this.resetLoadingState();
|
|
398
|
+
} else if (fragCurrent) {
|
|
399
|
+
// Seeking while frag load is in progress
|
|
400
|
+
const tolerance = config.maxFragLookUpTolerance;
|
|
401
|
+
const fragStartOffset = fragCurrent.start - tolerance;
|
|
402
|
+
const fragEndOffset =
|
|
403
|
+
fragCurrent.start + fragCurrent.duration + tolerance;
|
|
404
|
+
// if seeking out of buffered range or into new one
|
|
405
|
+
if (
|
|
406
|
+
noFowardBuffer ||
|
|
407
|
+
fragEndOffset < bufferInfo.start ||
|
|
408
|
+
fragStartOffset > bufferInfo.end
|
|
409
|
+
) {
|
|
410
|
+
const beforeFragment = currentTime < fragStartOffset;
|
|
411
|
+
const pastFragment = currentTime > fragEndOffset;
|
|
412
|
+
// if the seek position is outside the current fragment range
|
|
413
|
+
if (beforeFragment || pastFragment) {
|
|
414
|
+
// Only abort an active fragment load if the seek is past the fragment or the fragment isn't nearly downloaded
|
|
415
|
+
if (
|
|
416
|
+
fragCurrent.loader &&
|
|
417
|
+
(pastFragment || !this.isFragmentNearlyDownloaded(fragCurrent))
|
|
418
|
+
) {
|
|
419
|
+
this.log(
|
|
420
|
+
`Cancelling fragment load for seek (sn: ${fragCurrent.sn}) - ${beforeFragment ? 'backward' : 'forward'} seek`,
|
|
421
|
+
);
|
|
422
|
+
fragCurrent.abortRequests();
|
|
423
|
+
this.resetLoadingState();
|
|
424
|
+
}
|
|
425
|
+
this.fragPrevious = null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (media) {
|
|
431
|
+
// Remove gap fragments
|
|
432
|
+
this.fragmentTracker.removeFragmentsInRange(
|
|
433
|
+
currentTime,
|
|
434
|
+
Infinity,
|
|
435
|
+
this.playlistType,
|
|
436
|
+
true,
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
// Don't set lastCurrentTime with backward seeks (allows for frag selection with strict tolerances)
|
|
440
|
+
const lastCurrentTime = this.lastCurrentTime;
|
|
441
|
+
if (currentTime > lastCurrentTime) {
|
|
442
|
+
this.lastCurrentTime = currentTime;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!this.loadingParts) {
|
|
446
|
+
const bufferEnd = Math.max(bufferInfo.end, currentTime);
|
|
447
|
+
const shouldLoadParts = this.shouldLoadParts(
|
|
448
|
+
this.getLevelDetails(),
|
|
449
|
+
bufferEnd,
|
|
450
|
+
);
|
|
451
|
+
if (shouldLoadParts) {
|
|
452
|
+
this.log(
|
|
453
|
+
`LL-Part loading ON after seeking to ${currentTime.toFixed(
|
|
454
|
+
2,
|
|
455
|
+
)} with buffer @${bufferEnd.toFixed(2)}`,
|
|
456
|
+
);
|
|
457
|
+
this.loadingParts = shouldLoadParts;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// in case seeking occurs although no media buffered, adjust startPosition and nextLoadPosition to seek target
|
|
463
|
+
const bufferEmpty = !BufferHelper.isBuffered(media, currentTime);
|
|
464
|
+
if (!this.hls.hasEnoughToStart || bufferEmpty) {
|
|
465
|
+
this.log(
|
|
466
|
+
`Setting ${bufferEmpty ? 'startPosition' : 'nextLoadPosition'} to ${currentTime} for seek without enough to start`,
|
|
467
|
+
);
|
|
468
|
+
this.nextLoadPosition = currentTime;
|
|
469
|
+
if (bufferEmpty) {
|
|
470
|
+
this.startPosition = currentTime;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (noFowardBuffer && this.state === State.IDLE) {
|
|
475
|
+
// Async tick to speed up processing
|
|
476
|
+
this.tickImmediate();
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
protected onMediaEnded = () => {
|
|
481
|
+
// reset startPosition and lastCurrentTime to restart playback @ stream beginning
|
|
482
|
+
this.log(`setting startPosition to 0 because media ended`);
|
|
483
|
+
this.startPosition = this.lastCurrentTime = 0;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
protected onManifestLoaded(
|
|
487
|
+
event: Events.MANIFEST_LOADED,
|
|
488
|
+
data: ManifestLoadedData,
|
|
489
|
+
): void {
|
|
490
|
+
this.startTimeOffset = data.startTimeOffset;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
protected onHandlerDestroying() {
|
|
494
|
+
this.stopLoad();
|
|
495
|
+
if (this.transmuxer) {
|
|
496
|
+
this.transmuxer.destroy();
|
|
497
|
+
this.transmuxer = null;
|
|
498
|
+
}
|
|
499
|
+
super.onHandlerDestroying();
|
|
500
|
+
// @ts-ignore
|
|
501
|
+
this.hls = this.onMediaSeeking = this.onMediaEnded = null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
protected onHandlerDestroyed() {
|
|
505
|
+
this.state = State.STOPPED;
|
|
506
|
+
if (this.fragmentLoader) {
|
|
507
|
+
this.fragmentLoader.destroy();
|
|
508
|
+
}
|
|
509
|
+
if (this.keyLoader) {
|
|
510
|
+
this.keyLoader.destroy();
|
|
511
|
+
}
|
|
512
|
+
if (this.decrypter) {
|
|
513
|
+
this.decrypter.destroy();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
this.hls =
|
|
517
|
+
this.log =
|
|
518
|
+
this.warn =
|
|
519
|
+
this.decrypter =
|
|
520
|
+
this.keyLoader =
|
|
521
|
+
this.fragmentLoader =
|
|
522
|
+
this.fragmentTracker =
|
|
523
|
+
null as any;
|
|
524
|
+
super.onHandlerDestroyed();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
protected loadFragment(
|
|
528
|
+
frag: MediaFragment,
|
|
529
|
+
level: Level,
|
|
530
|
+
targetBufferTime: number,
|
|
531
|
+
) {
|
|
532
|
+
this.startFragRequested = true;
|
|
533
|
+
this._loadFragForPlayback(frag, level, targetBufferTime);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private _loadFragForPlayback(
|
|
537
|
+
fragment: MediaFragment,
|
|
538
|
+
level: Level,
|
|
539
|
+
targetBufferTime: number,
|
|
540
|
+
) {
|
|
541
|
+
const progressCallback: FragmentLoadProgressCallback = (
|
|
542
|
+
data: FragLoadedData,
|
|
543
|
+
) => {
|
|
544
|
+
const frag = data.frag;
|
|
545
|
+
if (this.fragContextChanged(frag)) {
|
|
546
|
+
this.warn(
|
|
547
|
+
`${frag.type} sn: ${frag.sn}${
|
|
548
|
+
data.part ? ' part: ' + data.part.index : ''
|
|
549
|
+
} of ${this.fragInfo(frag, false, data.part)}) was dropped during download.`,
|
|
550
|
+
);
|
|
551
|
+
this.fragmentTracker.removeFragment(frag);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
frag.stats.chunkCount++;
|
|
555
|
+
this._handleFragmentLoadProgress(data);
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
this._doFragLoad(fragment, level, targetBufferTime, progressCallback)
|
|
559
|
+
.then((data) => {
|
|
560
|
+
if (!data) {
|
|
561
|
+
// if we're here we probably needed to backtrack or are waiting for more parts
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const state = this.state;
|
|
565
|
+
const frag = data.frag;
|
|
566
|
+
if (this.fragContextChanged(frag)) {
|
|
567
|
+
if (
|
|
568
|
+
state === State.FRAG_LOADING ||
|
|
569
|
+
(!this.fragCurrent && state === State.PARSING)
|
|
570
|
+
) {
|
|
571
|
+
this.fragmentTracker.removeFragment(frag);
|
|
572
|
+
this.state = State.IDLE;
|
|
573
|
+
}
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if ('payload' in data) {
|
|
578
|
+
this.log(
|
|
579
|
+
`Loaded ${frag.type} sn: ${frag.sn} of ${this.playlistLabel()} ${frag.level}`,
|
|
580
|
+
);
|
|
581
|
+
this.hls.trigger(Events.FRAG_LOADED, data);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Pass through the whole payload; controllers not implementing progressive loading receive data from this callback
|
|
585
|
+
this._handleFragmentLoadComplete(data);
|
|
586
|
+
})
|
|
587
|
+
.catch((reason) => {
|
|
588
|
+
if (this.state === State.STOPPED || this.state === State.ERROR) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
this.warn(`Frag error: ${reason?.message || reason}`);
|
|
592
|
+
this.resetFragmentLoading(fragment);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
protected clearTrackerIfNeeded(frag: Fragment) {
|
|
597
|
+
const { fragmentTracker } = this;
|
|
598
|
+
const fragState = fragmentTracker.getState(frag);
|
|
599
|
+
if (fragState === FragmentState.APPENDING) {
|
|
600
|
+
// Lower the max buffer length and try again
|
|
601
|
+
const playlistType = frag.type as PlaylistLevelType;
|
|
602
|
+
const bufferedInfo = this.getFwdBufferInfo(
|
|
603
|
+
this.mediaBuffer,
|
|
604
|
+
playlistType,
|
|
605
|
+
);
|
|
606
|
+
const minForwardBufferLength = Math.max(
|
|
607
|
+
frag.duration,
|
|
608
|
+
bufferedInfo ? bufferedInfo.len : this.config.maxBufferLength,
|
|
609
|
+
);
|
|
610
|
+
// If backtracking, always remove from the tracker without reducing max buffer length
|
|
611
|
+
const backtrackFragment = this.backtrackFragment as Fragment | undefined;
|
|
612
|
+
const backtracked = backtrackFragment
|
|
613
|
+
? (frag.sn as number) - (backtrackFragment.sn as number)
|
|
614
|
+
: 0;
|
|
615
|
+
if (
|
|
616
|
+
backtracked === 1 ||
|
|
617
|
+
this.reduceMaxBufferLength(minForwardBufferLength, frag.duration)
|
|
618
|
+
) {
|
|
619
|
+
fragmentTracker.removeFragment(frag);
|
|
620
|
+
}
|
|
621
|
+
} else if (this.mediaBuffer?.buffered.length === 0) {
|
|
622
|
+
// Stop gap for bad tracker / buffer flush behavior
|
|
623
|
+
fragmentTracker.removeAllFragments();
|
|
624
|
+
} else if (fragmentTracker.hasParts(frag.type)) {
|
|
625
|
+
// In low latency mode, remove fragments for which only some parts were buffered
|
|
626
|
+
fragmentTracker.detectPartialFragments({
|
|
627
|
+
frag,
|
|
628
|
+
part: null,
|
|
629
|
+
stats: frag.stats,
|
|
630
|
+
id: frag.type,
|
|
631
|
+
});
|
|
632
|
+
if (fragmentTracker.getState(frag) === FragmentState.PARTIAL) {
|
|
633
|
+
fragmentTracker.removeFragment(frag);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
protected checkLiveUpdate(details: LevelDetails) {
|
|
639
|
+
if (details.updated && !details.live) {
|
|
640
|
+
// Live stream ended, update fragment tracker
|
|
641
|
+
const lastFragment = details.fragments[details.fragments.length - 1];
|
|
642
|
+
this.fragmentTracker.detectPartialFragments({
|
|
643
|
+
frag: lastFragment,
|
|
644
|
+
part: null,
|
|
645
|
+
stats: lastFragment.stats,
|
|
646
|
+
id: lastFragment.type,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (!details.fragments[0]) {
|
|
650
|
+
details.deltaUpdateFailed = true;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
protected waitForLive(levelInfo: Level) {
|
|
655
|
+
const details = levelInfo.details;
|
|
656
|
+
return (
|
|
657
|
+
details?.live &&
|
|
658
|
+
details.type !== 'EVENT' &&
|
|
659
|
+
(this.levelLastLoaded !== levelInfo || details.expired)
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
protected flushMainBuffer(
|
|
664
|
+
startOffset: number,
|
|
665
|
+
endOffset: number,
|
|
666
|
+
type: SourceBufferName | null = null,
|
|
667
|
+
) {
|
|
668
|
+
if (!(startOffset - endOffset)) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// When alternate audio is playing, the audio-stream-controller is responsible for the audio buffer. Otherwise,
|
|
672
|
+
// passing a null type flushes both buffers
|
|
673
|
+
const flushScope: BufferFlushingData = { startOffset, endOffset, type };
|
|
674
|
+
this.hls.trigger(Events.BUFFER_FLUSHING, flushScope);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
protected _loadInitSegment(fragment: Fragment, level: Level) {
|
|
678
|
+
this._doFragLoad(fragment, level)
|
|
679
|
+
.then((data) => {
|
|
680
|
+
const frag = data?.frag;
|
|
681
|
+
if (!frag || this.fragContextChanged(frag) || !this.levels) {
|
|
682
|
+
throw new Error('init load aborted');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return data;
|
|
686
|
+
})
|
|
687
|
+
.then((data: FragLoadedData) => {
|
|
688
|
+
const { hls } = this;
|
|
689
|
+
const { frag, payload } = data;
|
|
690
|
+
const decryptData = frag.decryptdata;
|
|
691
|
+
|
|
692
|
+
// check to see if the payload needs to be decrypted
|
|
693
|
+
if (
|
|
694
|
+
payload &&
|
|
695
|
+
payload.byteLength > 0 &&
|
|
696
|
+
decryptData?.key &&
|
|
697
|
+
decryptData.iv &&
|
|
698
|
+
isFullSegmentEncryption(decryptData.method)
|
|
699
|
+
) {
|
|
700
|
+
const startTime = self.performance.now();
|
|
701
|
+
// decrypt init segment data
|
|
702
|
+
return this.decrypter
|
|
703
|
+
.decrypt(
|
|
704
|
+
new Uint8Array(payload),
|
|
705
|
+
decryptData.key.buffer,
|
|
706
|
+
decryptData.iv.buffer,
|
|
707
|
+
getAesModeFromFullSegmentMethod(decryptData.method),
|
|
708
|
+
)
|
|
709
|
+
.catch((err) => {
|
|
710
|
+
hls.trigger(Events.ERROR, {
|
|
711
|
+
type: ErrorTypes.MEDIA_ERROR,
|
|
712
|
+
details: ErrorDetails.FRAG_DECRYPT_ERROR,
|
|
713
|
+
fatal: false,
|
|
714
|
+
error: err,
|
|
715
|
+
reason: err.message,
|
|
716
|
+
frag,
|
|
717
|
+
});
|
|
718
|
+
throw err;
|
|
719
|
+
})
|
|
720
|
+
.then((decryptedData) => {
|
|
721
|
+
const endTime = self.performance.now();
|
|
722
|
+
hls.trigger(Events.FRAG_DECRYPTED, {
|
|
723
|
+
frag,
|
|
724
|
+
payload: decryptedData,
|
|
725
|
+
stats: {
|
|
726
|
+
tstart: startTime,
|
|
727
|
+
tdecrypt: endTime,
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
data.payload = decryptedData;
|
|
731
|
+
return this.completeInitSegmentLoad(data);
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
return this.completeInitSegmentLoad(data);
|
|
735
|
+
})
|
|
736
|
+
.catch((reason) => {
|
|
737
|
+
if (this.state === State.STOPPED || this.state === State.ERROR) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
this.warn(reason);
|
|
741
|
+
this.resetFragmentLoading(fragment);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private completeInitSegmentLoad(data: FragLoadedData) {
|
|
746
|
+
const { levels } = this;
|
|
747
|
+
if (!levels) {
|
|
748
|
+
throw new Error('init load aborted, missing levels');
|
|
749
|
+
}
|
|
750
|
+
const stats = data.frag.stats;
|
|
751
|
+
if (this.state !== State.STOPPED) {
|
|
752
|
+
this.state = State.IDLE;
|
|
753
|
+
}
|
|
754
|
+
data.frag.data = new Uint8Array(data.payload);
|
|
755
|
+
stats.parsing.start = stats.buffering.start = self.performance.now();
|
|
756
|
+
stats.parsing.end = stats.buffering.end = self.performance.now();
|
|
757
|
+
this.tick();
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
protected unhandledEncryptionError(
|
|
761
|
+
initSegment: InitSegmentData,
|
|
762
|
+
frag: Fragment,
|
|
763
|
+
): boolean {
|
|
764
|
+
const tracks = initSegment.tracks;
|
|
765
|
+
if (
|
|
766
|
+
tracks &&
|
|
767
|
+
!frag.encrypted &&
|
|
768
|
+
(tracks.audio?.encrypted || tracks.video?.encrypted) &&
|
|
769
|
+
(!this.config.emeEnabled || !this.keyLoader.emeController)
|
|
770
|
+
) {
|
|
771
|
+
const media = this.media;
|
|
772
|
+
const error = new Error(
|
|
773
|
+
__USE_EME_DRM__
|
|
774
|
+
? `Encrypted track with no key in ${this.fragInfo(frag)} (media ${media ? 'attached mediaKeys: ' + media.mediaKeys : 'detached'})`
|
|
775
|
+
: 'EME not supported (light build)',
|
|
776
|
+
);
|
|
777
|
+
this.warn(error.message);
|
|
778
|
+
// Ignore if media is detached or mediaKeys are set
|
|
779
|
+
if (!media || media.mediaKeys) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
this.hls.trigger(Events.ERROR, {
|
|
783
|
+
type: ErrorTypes.KEY_SYSTEM_ERROR,
|
|
784
|
+
details: ErrorDetails.KEY_SYSTEM_NO_KEYS,
|
|
785
|
+
fatal: !__USE_EME_DRM__,
|
|
786
|
+
error,
|
|
787
|
+
frag,
|
|
788
|
+
});
|
|
789
|
+
this.resetTransmuxer();
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
protected fragContextChanged(frag: Fragment | null) {
|
|
796
|
+
const { fragCurrent } = this;
|
|
797
|
+
return (
|
|
798
|
+
!frag ||
|
|
799
|
+
!fragCurrent ||
|
|
800
|
+
frag.sn !== fragCurrent.sn ||
|
|
801
|
+
frag.level !== fragCurrent.level
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
protected fragBufferedComplete(frag: Fragment, part: Part | null) {
|
|
806
|
+
const media = this.mediaBuffer ? this.mediaBuffer : this.media;
|
|
807
|
+
this.log(
|
|
808
|
+
`Buffered ${frag.type} sn: ${frag.sn}${
|
|
809
|
+
part ? ' part: ' + part.index : ''
|
|
810
|
+
} of ${this.fragInfo(frag, false, part)} > buffer:${
|
|
811
|
+
media
|
|
812
|
+
? TimeRanges.toString(BufferHelper.getBuffered(media))
|
|
813
|
+
: '(detached)'
|
|
814
|
+
})`,
|
|
815
|
+
);
|
|
816
|
+
if (isMediaFragment(frag)) {
|
|
817
|
+
if (frag.type !== PlaylistLevelType.SUBTITLE) {
|
|
818
|
+
const el = frag.elementaryStreams;
|
|
819
|
+
if (!Object.keys(el).some((type) => !!el[type])) {
|
|
820
|
+
// empty segment
|
|
821
|
+
this.state = State.IDLE;
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const level = this.levels?.[frag.level];
|
|
826
|
+
if (level?.fragmentError) {
|
|
827
|
+
this.log(
|
|
828
|
+
`Resetting level fragment error count of ${level.fragmentError} on frag buffered`,
|
|
829
|
+
);
|
|
830
|
+
level.fragmentError = 0;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
this.state = State.IDLE;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
protected _handleFragmentLoadComplete(fragLoadedEndData: PartsLoadedData) {
|
|
837
|
+
const { transmuxer } = this;
|
|
838
|
+
if (!transmuxer) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const { frag, part, partsLoaded } = fragLoadedEndData;
|
|
842
|
+
// If we did not load parts, or loaded all parts, we have complete (not partial) fragment data
|
|
843
|
+
const complete =
|
|
844
|
+
!partsLoaded ||
|
|
845
|
+
partsLoaded.length === 0 ||
|
|
846
|
+
partsLoaded.some((fragLoaded) => !fragLoaded);
|
|
847
|
+
const chunkMeta = new ChunkMetadata(
|
|
848
|
+
frag.level,
|
|
849
|
+
frag.sn as number,
|
|
850
|
+
frag.stats.chunkCount + 1,
|
|
851
|
+
0,
|
|
852
|
+
part ? part.index : -1,
|
|
853
|
+
!complete,
|
|
854
|
+
);
|
|
855
|
+
transmuxer.flush(chunkMeta);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
protected _handleFragmentLoadProgress(
|
|
859
|
+
frag: PartsLoadedData | FragLoadedData,
|
|
860
|
+
) {}
|
|
861
|
+
|
|
862
|
+
protected _doFragLoad(
|
|
863
|
+
frag: Fragment,
|
|
864
|
+
level: Level,
|
|
865
|
+
targetBufferTime: number | null = null,
|
|
866
|
+
progressCallback?: FragmentLoadProgressCallback,
|
|
867
|
+
): Promise<PartsLoadedData | FragLoadedData | null> {
|
|
868
|
+
this.fragCurrent = frag;
|
|
869
|
+
const details = level.details;
|
|
870
|
+
if (!this.levels || !details) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
`frag load aborted, missing level${details ? '' : ' detail'}s`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
let keyLoadingPromise: Promise<KeyLoadedData | void> | null = null;
|
|
877
|
+
if (frag.encrypted && !frag.decryptdata?.key) {
|
|
878
|
+
this.log(
|
|
879
|
+
`Loading key for ${frag.sn} of [${details.startSN}-${details.endSN}], ${this.playlistLabel()} ${frag.level}`,
|
|
880
|
+
);
|
|
881
|
+
this.state = State.KEY_LOADING;
|
|
882
|
+
this.fragCurrent = frag;
|
|
883
|
+
keyLoadingPromise = this.keyLoader.load(frag).then((keyLoadedData) => {
|
|
884
|
+
if (!this.fragContextChanged(keyLoadedData.frag)) {
|
|
885
|
+
this.hls.trigger(Events.KEY_LOADED, keyLoadedData);
|
|
886
|
+
if (this.state === State.KEY_LOADING) {
|
|
887
|
+
this.state = State.IDLE;
|
|
888
|
+
}
|
|
889
|
+
return keyLoadedData;
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
this.hls.trigger(Events.KEY_LOADING, { frag });
|
|
893
|
+
if ((this.fragCurrent as Fragment | null) === null) {
|
|
894
|
+
this.log(`context changed in KEY_LOADING`);
|
|
895
|
+
return Promise.resolve(null);
|
|
896
|
+
}
|
|
897
|
+
} else if (!frag.encrypted) {
|
|
898
|
+
keyLoadingPromise = this.keyLoader.loadClear(
|
|
899
|
+
frag,
|
|
900
|
+
details.encryptedFragments,
|
|
901
|
+
this.startFragRequested,
|
|
902
|
+
);
|
|
903
|
+
if (keyLoadingPromise) {
|
|
904
|
+
this.log(`[eme] blocking frag load until media-keys acquired`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const fragPrevious = this.fragPrevious;
|
|
909
|
+
if (
|
|
910
|
+
isMediaFragment(frag) &&
|
|
911
|
+
(!fragPrevious || frag.sn !== fragPrevious.sn)
|
|
912
|
+
) {
|
|
913
|
+
const shouldLoadParts = this.shouldLoadParts(level.details, frag.end);
|
|
914
|
+
if (shouldLoadParts !== this.loadingParts) {
|
|
915
|
+
this.log(
|
|
916
|
+
`LL-Part loading ${
|
|
917
|
+
shouldLoadParts ? 'ON' : 'OFF'
|
|
918
|
+
} loading sn ${fragPrevious?.sn}->${frag.sn}`,
|
|
919
|
+
);
|
|
920
|
+
this.loadingParts = shouldLoadParts;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
targetBufferTime = Math.max(frag.start, targetBufferTime || 0);
|
|
924
|
+
if (this.loadingParts && isMediaFragment(frag)) {
|
|
925
|
+
const partList = details.partList;
|
|
926
|
+
if (partList && progressCallback) {
|
|
927
|
+
if (targetBufferTime > details.fragmentEnd && details.fragmentHint) {
|
|
928
|
+
frag = details.fragmentHint;
|
|
929
|
+
}
|
|
930
|
+
const partIndex = this.getNextPart(partList, frag, targetBufferTime);
|
|
931
|
+
if (partIndex > -1) {
|
|
932
|
+
const part = partList[partIndex];
|
|
933
|
+
frag = this.fragCurrent = part.fragment;
|
|
934
|
+
this.log(
|
|
935
|
+
`Loading ${frag.type} sn: ${frag.sn} part: ${part.index} (${partIndex}/${partList.length - 1}) of ${this.fragInfo(frag, false, part)}) cc: ${
|
|
936
|
+
frag.cc
|
|
937
|
+
} [${details.startSN}-${details.endSN}], target: ${parseFloat(
|
|
938
|
+
targetBufferTime.toFixed(3),
|
|
939
|
+
)}`,
|
|
940
|
+
);
|
|
941
|
+
this.nextLoadPosition = part.start + part.duration;
|
|
942
|
+
this.state = State.FRAG_LOADING;
|
|
943
|
+
let result: Promise<PartsLoadedData | FragLoadedData | null>;
|
|
944
|
+
if (keyLoadingPromise) {
|
|
945
|
+
result = keyLoadingPromise
|
|
946
|
+
.then((keyLoadedData) => {
|
|
947
|
+
if (
|
|
948
|
+
!keyLoadedData ||
|
|
949
|
+
this.fragContextChanged(keyLoadedData.frag)
|
|
950
|
+
) {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
return this.doFragPartsLoad(
|
|
954
|
+
frag,
|
|
955
|
+
part,
|
|
956
|
+
level,
|
|
957
|
+
progressCallback,
|
|
958
|
+
);
|
|
959
|
+
})
|
|
960
|
+
.catch((error) => this.handleFragLoadError(error));
|
|
961
|
+
} else {
|
|
962
|
+
result = this.doFragPartsLoad(
|
|
963
|
+
frag,
|
|
964
|
+
part,
|
|
965
|
+
level,
|
|
966
|
+
progressCallback,
|
|
967
|
+
).catch((error: LoadError) => this.handleFragLoadError(error));
|
|
968
|
+
}
|
|
969
|
+
this.hls.trigger(Events.FRAG_LOADING, {
|
|
970
|
+
frag,
|
|
971
|
+
part,
|
|
972
|
+
targetBufferTime,
|
|
973
|
+
});
|
|
974
|
+
if (this.fragCurrent === null) {
|
|
975
|
+
return Promise.reject(
|
|
976
|
+
new Error(
|
|
977
|
+
`frag load aborted, context changed in FRAG_LOADING parts`,
|
|
978
|
+
),
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
return result;
|
|
982
|
+
} else if (
|
|
983
|
+
!frag.url ||
|
|
984
|
+
this.loadedEndOfParts(partList, targetBufferTime)
|
|
985
|
+
) {
|
|
986
|
+
// Fragment hint has no parts
|
|
987
|
+
return Promise.resolve(null);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (isMediaFragment(frag) && this.loadingParts) {
|
|
993
|
+
this.log(
|
|
994
|
+
`LL-Part loading OFF after next part miss @${targetBufferTime.toFixed(
|
|
995
|
+
2,
|
|
996
|
+
)} Check buffer at sn: ${frag.sn} loaded parts: ${details.partList?.filter((p) => p.loaded).map((p) => `[${p.start}-${p.end}]`)}`,
|
|
997
|
+
);
|
|
998
|
+
this.loadingParts = false;
|
|
999
|
+
} else if (!frag.url) {
|
|
1000
|
+
// Selected fragment hint for part but not loading parts
|
|
1001
|
+
return Promise.resolve(null);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
this.log(
|
|
1005
|
+
`Loading ${frag.type} sn: ${frag.sn} of ${this.fragInfo(frag, false)}) cc: ${frag.cc} ${
|
|
1006
|
+
'[' + details.startSN + '-' + details.endSN + ']'
|
|
1007
|
+
}, target: ${parseFloat(targetBufferTime.toFixed(3))}`,
|
|
1008
|
+
);
|
|
1009
|
+
// Don't update nextLoadPosition for fragments which are not buffered
|
|
1010
|
+
if (Number.isFinite(frag.sn as number) && !this.bitrateTest) {
|
|
1011
|
+
this.nextLoadPosition = frag.start + frag.duration;
|
|
1012
|
+
}
|
|
1013
|
+
this.state = State.FRAG_LOADING;
|
|
1014
|
+
|
|
1015
|
+
// Load key before streaming fragment data
|
|
1016
|
+
const dataOnProgress =
|
|
1017
|
+
this.config.progressive && frag.type !== PlaylistLevelType.SUBTITLE;
|
|
1018
|
+
let result: Promise<PartsLoadedData | FragLoadedData | null>;
|
|
1019
|
+
if (dataOnProgress && keyLoadingPromise) {
|
|
1020
|
+
result = keyLoadingPromise
|
|
1021
|
+
.then((keyLoadedData) => {
|
|
1022
|
+
if (!keyLoadedData || this.fragContextChanged(keyLoadedData.frag)) {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
return this.fragmentLoader.load(frag, progressCallback);
|
|
1026
|
+
})
|
|
1027
|
+
.catch((error) => this.handleFragLoadError(error));
|
|
1028
|
+
} else {
|
|
1029
|
+
// load unencrypted fragment data with progress event,
|
|
1030
|
+
// or handle fragment result after key and fragment are finished loading
|
|
1031
|
+
result = Promise.all([
|
|
1032
|
+
this.fragmentLoader.load(
|
|
1033
|
+
frag,
|
|
1034
|
+
dataOnProgress ? progressCallback : undefined,
|
|
1035
|
+
),
|
|
1036
|
+
keyLoadingPromise,
|
|
1037
|
+
])
|
|
1038
|
+
.then(([fragLoadedData]) => {
|
|
1039
|
+
if (!dataOnProgress && progressCallback) {
|
|
1040
|
+
progressCallback(fragLoadedData);
|
|
1041
|
+
}
|
|
1042
|
+
return fragLoadedData;
|
|
1043
|
+
})
|
|
1044
|
+
.catch((error) => this.handleFragLoadError(error));
|
|
1045
|
+
}
|
|
1046
|
+
this.hls.trigger(Events.FRAG_LOADING, { frag, targetBufferTime });
|
|
1047
|
+
if (this.fragCurrent === null) {
|
|
1048
|
+
return Promise.reject(
|
|
1049
|
+
new Error(`frag load aborted, context changed in FRAG_LOADING`),
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
return result;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
private doFragPartsLoad(
|
|
1056
|
+
frag: Fragment,
|
|
1057
|
+
fromPart: Part,
|
|
1058
|
+
level: Level,
|
|
1059
|
+
progressCallback: FragmentLoadProgressCallback,
|
|
1060
|
+
): Promise<PartsLoadedData | null> {
|
|
1061
|
+
return new Promise(
|
|
1062
|
+
(resolve: ResolveFragLoaded, reject: RejectFragLoaded) => {
|
|
1063
|
+
const partsLoaded: FragLoadedData[] = [];
|
|
1064
|
+
const initialPartList = level.details?.partList;
|
|
1065
|
+
const loadPart = (part: Part) => {
|
|
1066
|
+
this.fragmentLoader
|
|
1067
|
+
.loadPart(frag, part, progressCallback)
|
|
1068
|
+
.then((partLoadedData: FragLoadedData) => {
|
|
1069
|
+
partsLoaded[part.index] = partLoadedData;
|
|
1070
|
+
const loadedPart = partLoadedData.part as Part;
|
|
1071
|
+
this.hls.trigger(Events.FRAG_LOADED, partLoadedData);
|
|
1072
|
+
const nextPart =
|
|
1073
|
+
getPartWith(level.details, frag.sn as number, part.index + 1) ||
|
|
1074
|
+
findPart(initialPartList, frag.sn as number, part.index + 1);
|
|
1075
|
+
if (nextPart) {
|
|
1076
|
+
loadPart(nextPart);
|
|
1077
|
+
} else {
|
|
1078
|
+
return resolve({
|
|
1079
|
+
frag,
|
|
1080
|
+
part: loadedPart,
|
|
1081
|
+
partsLoaded,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
})
|
|
1085
|
+
.catch(reject);
|
|
1086
|
+
};
|
|
1087
|
+
loadPart(fromPart);
|
|
1088
|
+
},
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
private handleFragLoadError(
|
|
1093
|
+
error: LoadError | Error | (Error & { data: ErrorData }),
|
|
1094
|
+
) {
|
|
1095
|
+
if ('data' in error) {
|
|
1096
|
+
const data = error.data;
|
|
1097
|
+
if (data.frag && data.details === ErrorDetails.INTERNAL_ABORTED) {
|
|
1098
|
+
this.handleFragLoadAborted(data.frag, data.part);
|
|
1099
|
+
} else if (data.frag && data.type === ErrorTypes.KEY_SYSTEM_ERROR) {
|
|
1100
|
+
data.frag.abortRequests();
|
|
1101
|
+
this.resetStartWhenNotLoaded();
|
|
1102
|
+
this.resetFragmentLoading(data.frag);
|
|
1103
|
+
} else {
|
|
1104
|
+
this.hls.trigger(Events.ERROR, data as ErrorData);
|
|
1105
|
+
}
|
|
1106
|
+
} else {
|
|
1107
|
+
this.hls.trigger(Events.ERROR, {
|
|
1108
|
+
type: ErrorTypes.OTHER_ERROR,
|
|
1109
|
+
details: ErrorDetails.INTERNAL_EXCEPTION,
|
|
1110
|
+
err: error,
|
|
1111
|
+
error,
|
|
1112
|
+
fatal: true,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
protected _handleTransmuxerFlush(chunkMeta: ChunkMetadata) {
|
|
1119
|
+
const context = this.getCurrentContext(chunkMeta);
|
|
1120
|
+
if (!context || this.state !== State.PARSING) {
|
|
1121
|
+
if (
|
|
1122
|
+
!this.fragCurrent &&
|
|
1123
|
+
this.state !== State.STOPPED &&
|
|
1124
|
+
this.state !== State.ERROR
|
|
1125
|
+
) {
|
|
1126
|
+
this.state = State.IDLE;
|
|
1127
|
+
}
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const { frag, part, level } = context;
|
|
1131
|
+
const now = self.performance.now();
|
|
1132
|
+
frag.stats.parsing.end = now;
|
|
1133
|
+
if (part) {
|
|
1134
|
+
part.stats.parsing.end = now;
|
|
1135
|
+
}
|
|
1136
|
+
// See if part loading should be disabled/enabled based on buffer and playback position.
|
|
1137
|
+
const levelDetails = this.getLevelDetails();
|
|
1138
|
+
const loadingPartsAtEdge = levelDetails && frag.sn > levelDetails.endSN;
|
|
1139
|
+
const shouldLoadParts =
|
|
1140
|
+
loadingPartsAtEdge || this.shouldLoadParts(levelDetails, frag.end);
|
|
1141
|
+
if (shouldLoadParts !== this.loadingParts) {
|
|
1142
|
+
this.log(
|
|
1143
|
+
`LL-Part loading ${
|
|
1144
|
+
shouldLoadParts ? 'ON' : 'OFF'
|
|
1145
|
+
} after parsing segment ending @${frag.end.toFixed(2)}`,
|
|
1146
|
+
);
|
|
1147
|
+
this.loadingParts = shouldLoadParts;
|
|
1148
|
+
}
|
|
1149
|
+
this.updateLevelTiming(frag, part, level, chunkMeta.partial);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private shouldLoadParts(
|
|
1153
|
+
details: LevelDetails | undefined,
|
|
1154
|
+
bufferEnd: number,
|
|
1155
|
+
): boolean {
|
|
1156
|
+
if (this.config.lowLatencyMode) {
|
|
1157
|
+
if (!details) {
|
|
1158
|
+
return this.loadingParts;
|
|
1159
|
+
}
|
|
1160
|
+
if (details.partList) {
|
|
1161
|
+
// Buffer must be ahead of first part + duration of parts after last segment
|
|
1162
|
+
// and playback must be at or past segment adjacent to part list
|
|
1163
|
+
const firstPart = details.partList[0];
|
|
1164
|
+
const safePartStart =
|
|
1165
|
+
firstPart.end + (details.fragmentHint?.duration || 0);
|
|
1166
|
+
if (bufferEnd >= safePartStart) {
|
|
1167
|
+
const playhead = this.hls.hasEnoughToStart
|
|
1168
|
+
? this.media?.currentTime || this.lastCurrentTime
|
|
1169
|
+
: this.getLoadPosition();
|
|
1170
|
+
if (playhead > firstPart.start - firstPart.fragment.duration) {
|
|
1171
|
+
return true;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
protected getCurrentContext(
|
|
1180
|
+
chunkMeta: ChunkMetadata,
|
|
1181
|
+
): { frag: MediaFragment; part: Part | null; level: Level } | null {
|
|
1182
|
+
const { levels, fragCurrent } = this;
|
|
1183
|
+
const { level: levelIndex, sn, part: partIndex } = chunkMeta;
|
|
1184
|
+
if (!levels?.[levelIndex]) {
|
|
1185
|
+
this.warn(
|
|
1186
|
+
`Levels object was unset while buffering fragment ${sn} of ${this.playlistLabel()} ${levelIndex}. The current chunk will not be buffered.`,
|
|
1187
|
+
);
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
const level = levels[levelIndex];
|
|
1191
|
+
const levelDetails = level.details;
|
|
1192
|
+
|
|
1193
|
+
const part =
|
|
1194
|
+
partIndex > -1 ? getPartWith(levelDetails, sn, partIndex) : null;
|
|
1195
|
+
const frag = part
|
|
1196
|
+
? part.fragment
|
|
1197
|
+
: getFragmentWithSN(levelDetails, sn, fragCurrent);
|
|
1198
|
+
if (!frag) {
|
|
1199
|
+
return null;
|
|
1200
|
+
}
|
|
1201
|
+
if (fragCurrent && fragCurrent !== frag) {
|
|
1202
|
+
frag.stats = fragCurrent.stats;
|
|
1203
|
+
}
|
|
1204
|
+
return { frag, part, level };
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
protected bufferFragmentData(
|
|
1208
|
+
data: RemuxedTrack,
|
|
1209
|
+
frag: Fragment,
|
|
1210
|
+
part: Part | null,
|
|
1211
|
+
chunkMeta: ChunkMetadata,
|
|
1212
|
+
noBacktracking?: boolean,
|
|
1213
|
+
) {
|
|
1214
|
+
if (this.state !== State.PARSING) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const { data1, data2 } = data;
|
|
1219
|
+
let buffer = data1;
|
|
1220
|
+
if (data2) {
|
|
1221
|
+
// Combine the moof + mdat so that we buffer with a single append
|
|
1222
|
+
buffer = appendUint8Array(data1, data2);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (!buffer.length) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
const offsetTimestamp = this.initPTS[frag.cc] as
|
|
1229
|
+
| TimestampOffset
|
|
1230
|
+
| undefined;
|
|
1231
|
+
const offset = offsetTimestamp
|
|
1232
|
+
? -offsetTimestamp.baseTime / offsetTimestamp.timescale
|
|
1233
|
+
: undefined;
|
|
1234
|
+
const segment: BufferAppendingData = {
|
|
1235
|
+
type: data.type,
|
|
1236
|
+
frag,
|
|
1237
|
+
part,
|
|
1238
|
+
chunkMeta,
|
|
1239
|
+
offset,
|
|
1240
|
+
parent: frag.type,
|
|
1241
|
+
data: buffer,
|
|
1242
|
+
};
|
|
1243
|
+
this.hls.trigger(Events.BUFFER_APPENDING, segment);
|
|
1244
|
+
|
|
1245
|
+
if (data.dropped && data.independent && !part) {
|
|
1246
|
+
if (noBacktracking) {
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
// Clear buffer so that we reload previous segments sequentially if required
|
|
1250
|
+
this.flushBufferGap(frag);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
protected flushBufferGap(frag: Fragment) {
|
|
1255
|
+
const media = this.media;
|
|
1256
|
+
if (!media) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
// If currentTime is not buffered, clear the back buffer so that we can backtrack as much as needed
|
|
1260
|
+
if (!BufferHelper.isBuffered(media, media.currentTime)) {
|
|
1261
|
+
this.flushMainBuffer(0, frag.start);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
// Remove back-buffer without interrupting playback to allow back tracking
|
|
1265
|
+
const currentTime = media.currentTime;
|
|
1266
|
+
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
|
|
1267
|
+
const fragDuration = frag.duration;
|
|
1268
|
+
const segmentFraction = Math.min(
|
|
1269
|
+
this.config.maxFragLookUpTolerance * 2,
|
|
1270
|
+
fragDuration * 0.25,
|
|
1271
|
+
);
|
|
1272
|
+
const start = Math.max(
|
|
1273
|
+
Math.min(frag.start - segmentFraction, bufferInfo.end - segmentFraction),
|
|
1274
|
+
currentTime + segmentFraction,
|
|
1275
|
+
);
|
|
1276
|
+
if (frag.start - start > segmentFraction) {
|
|
1277
|
+
this.flushMainBuffer(start, frag.start);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
protected getFwdBufferInfo(
|
|
1282
|
+
bufferable: Bufferable | null,
|
|
1283
|
+
type: PlaylistLevelType,
|
|
1284
|
+
): BufferInfo | null {
|
|
1285
|
+
const pos = this.getLoadPosition();
|
|
1286
|
+
if (!Number.isFinite(pos)) {
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
const backwardSeek = this.lastCurrentTime > pos;
|
|
1290
|
+
const maxBufferHole =
|
|
1291
|
+
backwardSeek || this.media?.paused ? 0 : this.config.maxBufferHole;
|
|
1292
|
+
return this.getFwdBufferInfoAtPos(bufferable, pos, type, maxBufferHole);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
protected getFwdBufferInfoAtPos(
|
|
1296
|
+
bufferable: Bufferable | null,
|
|
1297
|
+
pos: number,
|
|
1298
|
+
type: PlaylistLevelType,
|
|
1299
|
+
maxBufferHole: number,
|
|
1300
|
+
): BufferInfo | null {
|
|
1301
|
+
const bufferInfo = BufferHelper.bufferInfo(bufferable, pos, maxBufferHole);
|
|
1302
|
+
// Workaround flaw in getting forward buffer when maxBufferHole is smaller than gap at current pos
|
|
1303
|
+
if (bufferInfo.len === 0 && bufferInfo.nextStart !== undefined) {
|
|
1304
|
+
const bufferedFragAtPos = this.fragmentTracker.getBufferedFrag(pos, type);
|
|
1305
|
+
if (
|
|
1306
|
+
bufferedFragAtPos &&
|
|
1307
|
+
(bufferInfo.nextStart <= bufferedFragAtPos.end || bufferedFragAtPos.gap)
|
|
1308
|
+
) {
|
|
1309
|
+
const gapDuration = Math.max(
|
|
1310
|
+
Math.min(bufferInfo.nextStart, bufferedFragAtPos.end) - pos,
|
|
1311
|
+
maxBufferHole,
|
|
1312
|
+
);
|
|
1313
|
+
return BufferHelper.bufferInfo(bufferable, pos, gapDuration);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return bufferInfo;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
protected getMaxBufferLength(levelBitrate?: number): number {
|
|
1320
|
+
const { config } = this;
|
|
1321
|
+
let maxBufLen: number;
|
|
1322
|
+
if (levelBitrate) {
|
|
1323
|
+
maxBufLen = Math.max(
|
|
1324
|
+
(8 * config.maxBufferSize) / levelBitrate,
|
|
1325
|
+
config.maxBufferLength,
|
|
1326
|
+
);
|
|
1327
|
+
} else {
|
|
1328
|
+
maxBufLen = config.maxBufferLength;
|
|
1329
|
+
}
|
|
1330
|
+
return Math.min(maxBufLen, config.maxMaxBufferLength);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
protected reduceMaxBufferLength(threshold: number, fragDuration: number) {
|
|
1334
|
+
const config = this.config;
|
|
1335
|
+
const minLength = Math.max(
|
|
1336
|
+
Math.min(threshold - fragDuration, config.maxBufferLength),
|
|
1337
|
+
fragDuration,
|
|
1338
|
+
);
|
|
1339
|
+
const reducedLength = Math.max(
|
|
1340
|
+
threshold - fragDuration * 3,
|
|
1341
|
+
config.maxMaxBufferLength / 2,
|
|
1342
|
+
minLength,
|
|
1343
|
+
);
|
|
1344
|
+
if (reducedLength >= minLength) {
|
|
1345
|
+
// reduce max buffer length as it might be too high. we do this to avoid loop flushing ...
|
|
1346
|
+
config.maxMaxBufferLength = reducedLength;
|
|
1347
|
+
this.warn(`Reduce max buffer length to ${reducedLength}s`);
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
return false;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
protected getAppendedFrag(position: number): Fragment | null {
|
|
1354
|
+
const fragOrPart = (this.fragmentTracker as any)
|
|
1355
|
+
? this.fragmentTracker.getAppendedFrag(position, this.playlistType)
|
|
1356
|
+
: null;
|
|
1357
|
+
if (fragOrPart && 'fragment' in fragOrPart) {
|
|
1358
|
+
return fragOrPart.fragment;
|
|
1359
|
+
}
|
|
1360
|
+
return fragOrPart;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
protected getNextFragment(
|
|
1364
|
+
pos: number,
|
|
1365
|
+
levelDetails: LevelDetails,
|
|
1366
|
+
): Fragment | null {
|
|
1367
|
+
const fragments = levelDetails.fragments;
|
|
1368
|
+
const fragLen = fragments.length;
|
|
1369
|
+
|
|
1370
|
+
if (!fragLen) {
|
|
1371
|
+
return null;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// find fragment index, contiguous with end of buffer position
|
|
1375
|
+
const { config } = this;
|
|
1376
|
+
const start = fragments[0].start;
|
|
1377
|
+
const canLoadParts = config.lowLatencyMode && !!levelDetails.partList;
|
|
1378
|
+
let frag: MediaFragment | null = null;
|
|
1379
|
+
|
|
1380
|
+
if (levelDetails.live) {
|
|
1381
|
+
const initialLiveManifestSize = config.initialLiveManifestSize;
|
|
1382
|
+
if (fragLen < initialLiveManifestSize) {
|
|
1383
|
+
this.warn(
|
|
1384
|
+
`Not enough fragments to start playback (have: ${fragLen}, need: ${initialLiveManifestSize})`,
|
|
1385
|
+
);
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
// The real fragment start times for a live stream are only known after the PTS range for that level is known.
|
|
1389
|
+
// In order to discover the range, we load the best matching fragment for that level and demux it.
|
|
1390
|
+
// Do not load using live logic if the starting frag is requested - we want to use getFragmentAtPosition() so that
|
|
1391
|
+
// we get the fragment matching that start time
|
|
1392
|
+
if (
|
|
1393
|
+
(!levelDetails.PTSKnown &&
|
|
1394
|
+
!this.startFragRequested &&
|
|
1395
|
+
this.startPosition === -1) ||
|
|
1396
|
+
pos < start
|
|
1397
|
+
) {
|
|
1398
|
+
if (canLoadParts && !this.loadingParts) {
|
|
1399
|
+
this.log(`LL-Part loading ON for initial live fragment`);
|
|
1400
|
+
this.loadingParts = true;
|
|
1401
|
+
}
|
|
1402
|
+
frag = this.getInitialLiveFragment(levelDetails);
|
|
1403
|
+
const mainStart = this.hls.startPosition;
|
|
1404
|
+
const liveSyncPosition = this.hls.liveSyncPosition;
|
|
1405
|
+
const startPosition = frag
|
|
1406
|
+
? (mainStart !== -1 && mainStart >= start
|
|
1407
|
+
? mainStart
|
|
1408
|
+
: liveSyncPosition) || frag.start
|
|
1409
|
+
: pos;
|
|
1410
|
+
this.log(
|
|
1411
|
+
`Setting startPosition to ${startPosition} to match start frag at live edge. mainStart: ${mainStart} liveSyncPosition: ${liveSyncPosition} frag.start: ${frag?.start}`,
|
|
1412
|
+
);
|
|
1413
|
+
this.startPosition = this.nextLoadPosition = startPosition;
|
|
1414
|
+
}
|
|
1415
|
+
} else if (pos <= start) {
|
|
1416
|
+
// VoD playlist: if loadPosition before start of playlist, load first fragment
|
|
1417
|
+
frag = fragments[0];
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// If we haven't run into any special cases already, just load the fragment most closely matching the requested position
|
|
1421
|
+
if (!frag) {
|
|
1422
|
+
const end = this.loadingParts
|
|
1423
|
+
? levelDetails.partEnd
|
|
1424
|
+
: levelDetails.fragmentEnd;
|
|
1425
|
+
frag = this.getFragmentAtPosition(pos, end, levelDetails);
|
|
1426
|
+
}
|
|
1427
|
+
let programFrag = this.filterReplacedPrimary(frag, levelDetails);
|
|
1428
|
+
if (!programFrag && frag) {
|
|
1429
|
+
const curSNIdx = frag.sn - levelDetails.startSN;
|
|
1430
|
+
programFrag = this.filterReplacedPrimary(
|
|
1431
|
+
fragments[curSNIdx + 1] || null,
|
|
1432
|
+
levelDetails,
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
return this.mapToInitFragWhenRequired(programFrag);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
protected isLoopLoading(frag: Fragment, targetBufferTime: number): boolean {
|
|
1439
|
+
const trackerState = this.fragmentTracker.getState(frag);
|
|
1440
|
+
return (
|
|
1441
|
+
(trackerState === FragmentState.OK ||
|
|
1442
|
+
(trackerState === FragmentState.PARTIAL && !!frag.gap)) &&
|
|
1443
|
+
this.nextLoadPosition > targetBufferTime
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
protected getNextFragmentLoopLoading(
|
|
1448
|
+
frag: Fragment,
|
|
1449
|
+
levelDetails: LevelDetails,
|
|
1450
|
+
bufferInfo: BufferInfo,
|
|
1451
|
+
playlistType: PlaylistLevelType,
|
|
1452
|
+
maxBufLen: number,
|
|
1453
|
+
): Fragment | null {
|
|
1454
|
+
let nextFragment: Fragment | null = null;
|
|
1455
|
+
if (frag.gap) {
|
|
1456
|
+
nextFragment = this.getNextFragment(this.nextLoadPosition, levelDetails);
|
|
1457
|
+
if (nextFragment && !nextFragment.gap && bufferInfo.nextStart) {
|
|
1458
|
+
// Media buffered after GAP tags should not make the next buffer timerange exceed forward buffer length
|
|
1459
|
+
const nextbufferInfo = this.getFwdBufferInfoAtPos(
|
|
1460
|
+
this.mediaBuffer ? this.mediaBuffer : this.media,
|
|
1461
|
+
bufferInfo.nextStart,
|
|
1462
|
+
playlistType,
|
|
1463
|
+
0,
|
|
1464
|
+
);
|
|
1465
|
+
if (
|
|
1466
|
+
nextbufferInfo !== null &&
|
|
1467
|
+
bufferInfo.len + nextbufferInfo.len >= maxBufLen
|
|
1468
|
+
) {
|
|
1469
|
+
// Returning here might result in not finding an audio and video candiate to skip to
|
|
1470
|
+
const sn = nextFragment.sn;
|
|
1471
|
+
if (this.loopSn !== sn) {
|
|
1472
|
+
this.log(
|
|
1473
|
+
`buffer full after gaps in "${playlistType}" playlist starting at sn: ${sn}`,
|
|
1474
|
+
);
|
|
1475
|
+
this.loopSn = sn;
|
|
1476
|
+
}
|
|
1477
|
+
return null;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
this.loopSn = undefined;
|
|
1482
|
+
return nextFragment;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
protected get primaryPrefetch(): boolean {
|
|
1486
|
+
if (interstitialsEnabled(this.config)) {
|
|
1487
|
+
const playingInterstitial =
|
|
1488
|
+
this.hls.interstitialsManager?.playingItem?.event;
|
|
1489
|
+
if (playingInterstitial) {
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
return false;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
protected filterReplacedPrimary(
|
|
1497
|
+
frag: MediaFragment | null,
|
|
1498
|
+
details: LevelDetails | undefined,
|
|
1499
|
+
): MediaFragment | null {
|
|
1500
|
+
if (!frag) {
|
|
1501
|
+
return frag;
|
|
1502
|
+
}
|
|
1503
|
+
if (
|
|
1504
|
+
interstitialsEnabled(this.config) &&
|
|
1505
|
+
frag.type !== PlaylistLevelType.SUBTITLE
|
|
1506
|
+
) {
|
|
1507
|
+
// Do not load fragments outside the buffering schedule segment
|
|
1508
|
+
const interstitials = this.hls.interstitialsManager;
|
|
1509
|
+
const bufferingItem = interstitials?.bufferingItem;
|
|
1510
|
+
if (bufferingItem) {
|
|
1511
|
+
const bufferingInterstitial = bufferingItem.event;
|
|
1512
|
+
if (bufferingInterstitial) {
|
|
1513
|
+
// Do not stream fragments while buffering Interstitial Events (except for overlap at the start)
|
|
1514
|
+
if (
|
|
1515
|
+
bufferingInterstitial.appendInPlace ||
|
|
1516
|
+
Math.abs(frag.start - bufferingItem.start) > 1 ||
|
|
1517
|
+
bufferingItem.start === 0
|
|
1518
|
+
) {
|
|
1519
|
+
return null;
|
|
1520
|
+
}
|
|
1521
|
+
} else {
|
|
1522
|
+
// Limit fragment loading to media in schedule item
|
|
1523
|
+
if (frag.end <= bufferingItem.start && details?.live === false) {
|
|
1524
|
+
// fragment ends by schedule item start
|
|
1525
|
+
// this.fragmentTracker.fragBuffered(frag, true);
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
if (frag.start > bufferingItem.end && bufferingItem.nextEvent) {
|
|
1529
|
+
// fragment is past schedule item end
|
|
1530
|
+
// allow some overflow when not appending in place to prevent stalls
|
|
1531
|
+
if (
|
|
1532
|
+
bufferingItem.nextEvent.appendInPlace ||
|
|
1533
|
+
frag.start - bufferingItem.end > 1
|
|
1534
|
+
) {
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
// Skip loading of fragments that overlap completely with appendInPlace interstitials
|
|
1541
|
+
const playerQueue = interstitials?.playerQueue;
|
|
1542
|
+
if (playerQueue) {
|
|
1543
|
+
for (let i = playerQueue.length; i--; ) {
|
|
1544
|
+
const interstitial = playerQueue[i].interstitial;
|
|
1545
|
+
if (
|
|
1546
|
+
interstitial.appendInPlace &&
|
|
1547
|
+
frag.start >= interstitial.startTime &&
|
|
1548
|
+
frag.end <= interstitial.resumeTime
|
|
1549
|
+
) {
|
|
1550
|
+
return null;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
return frag;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
mapToInitFragWhenRequired(frag: Fragment | null): typeof frag {
|
|
1559
|
+
// If an initSegment is present, it must be buffered first
|
|
1560
|
+
if (frag?.initSegment && !frag.initSegment.data && !this.bitrateTest) {
|
|
1561
|
+
return frag.initSegment;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
return frag;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
getNextPart(
|
|
1568
|
+
partList: Part[],
|
|
1569
|
+
frag: Fragment,
|
|
1570
|
+
targetBufferTime: number,
|
|
1571
|
+
): number {
|
|
1572
|
+
let nextPart = -1;
|
|
1573
|
+
let contiguous = false;
|
|
1574
|
+
let independentAttrOmitted = true;
|
|
1575
|
+
for (let i = 0, len = partList.length; i < len; i++) {
|
|
1576
|
+
const part = partList[i];
|
|
1577
|
+
independentAttrOmitted = independentAttrOmitted && !part.independent;
|
|
1578
|
+
if (nextPart > -1 && targetBufferTime < part.start) {
|
|
1579
|
+
break;
|
|
1580
|
+
}
|
|
1581
|
+
const loaded = part.loaded;
|
|
1582
|
+
if (loaded) {
|
|
1583
|
+
nextPart = -1;
|
|
1584
|
+
} else if (
|
|
1585
|
+
contiguous ||
|
|
1586
|
+
((part.independent || independentAttrOmitted) && part.fragment === frag)
|
|
1587
|
+
) {
|
|
1588
|
+
if (part.fragment !== frag) {
|
|
1589
|
+
this.warn(
|
|
1590
|
+
`Need buffer at ${targetBufferTime} but next unloaded part starts at ${part.start}`,
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
nextPart = i;
|
|
1594
|
+
}
|
|
1595
|
+
contiguous = loaded;
|
|
1596
|
+
}
|
|
1597
|
+
return nextPart;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
private loadedEndOfParts(
|
|
1601
|
+
partList: Part[],
|
|
1602
|
+
targetBufferTime: number,
|
|
1603
|
+
): boolean {
|
|
1604
|
+
let part: Part;
|
|
1605
|
+
for (let i = partList.length; i--; ) {
|
|
1606
|
+
part = partList[i];
|
|
1607
|
+
if (!part.loaded) {
|
|
1608
|
+
return false;
|
|
1609
|
+
}
|
|
1610
|
+
if (targetBufferTime > part.start) {
|
|
1611
|
+
return true;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
return false;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/*
|
|
1618
|
+
This method is used find the best matching first fragment for a live playlist. This fragment is used to calculate the
|
|
1619
|
+
"sliding" of the playlist, which is its offset from the start of playback. After sliding we can compute the real
|
|
1620
|
+
start and end times for each fragment in the playlist (after which this method will not need to be called).
|
|
1621
|
+
*/
|
|
1622
|
+
protected getInitialLiveFragment(
|
|
1623
|
+
levelDetails: LevelDetails,
|
|
1624
|
+
): MediaFragment | null {
|
|
1625
|
+
const fragments = levelDetails.fragments;
|
|
1626
|
+
const fragPrevious = this.fragPrevious;
|
|
1627
|
+
let frag: MediaFragment | null = null;
|
|
1628
|
+
if (fragPrevious) {
|
|
1629
|
+
if (levelDetails.hasProgramDateTime) {
|
|
1630
|
+
// Prefer using PDT, because it can be accurate enough to choose the correct fragment without knowing the level sliding
|
|
1631
|
+
this.log(
|
|
1632
|
+
`Live playlist, switching playlist, load frag with same PDT: ${fragPrevious.programDateTime}`,
|
|
1633
|
+
);
|
|
1634
|
+
frag = findFragmentByPDT(
|
|
1635
|
+
fragments,
|
|
1636
|
+
fragPrevious.endProgramDateTime,
|
|
1637
|
+
this.config.maxFragLookUpTolerance,
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
if (!frag) {
|
|
1641
|
+
// SN does not need to be accurate between renditions, but depending on the packaging it may be so.
|
|
1642
|
+
const targetSN = (fragPrevious.sn as number) + 1;
|
|
1643
|
+
if (
|
|
1644
|
+
targetSN >= levelDetails.startSN &&
|
|
1645
|
+
targetSN <= levelDetails.endSN
|
|
1646
|
+
) {
|
|
1647
|
+
const fragNext = fragments[targetSN - levelDetails.startSN];
|
|
1648
|
+
// Ensure that we're staying within the continuity range, since PTS resets upon a new range
|
|
1649
|
+
if (fragPrevious.cc === fragNext.cc) {
|
|
1650
|
+
frag = fragNext;
|
|
1651
|
+
this.log(
|
|
1652
|
+
`Live playlist, switching playlist, load frag with next SN: ${
|
|
1653
|
+
frag!.sn
|
|
1654
|
+
}`,
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
// It's important to stay within the continuity range if available; otherwise the fragments in the playlist
|
|
1659
|
+
// will have the wrong start times
|
|
1660
|
+
if (!frag) {
|
|
1661
|
+
frag = findNearestWithCC(
|
|
1662
|
+
levelDetails,
|
|
1663
|
+
fragPrevious.cc,
|
|
1664
|
+
fragPrevious.end,
|
|
1665
|
+
);
|
|
1666
|
+
if (frag) {
|
|
1667
|
+
this.log(
|
|
1668
|
+
`Live playlist, switching playlist, load frag with same CC: ${frag.sn}`,
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
} else {
|
|
1674
|
+
// Find a new start fragment when fragPrevious is null
|
|
1675
|
+
const liveStart = this.hls.liveSyncPosition;
|
|
1676
|
+
if (liveStart !== null) {
|
|
1677
|
+
frag = this.getFragmentAtPosition(
|
|
1678
|
+
liveStart,
|
|
1679
|
+
this.bitrateTest ? levelDetails.fragmentEnd : levelDetails.edge,
|
|
1680
|
+
levelDetails,
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
return frag;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/*
|
|
1689
|
+
This method finds the best matching fragment given the provided position.
|
|
1690
|
+
*/
|
|
1691
|
+
protected getFragmentAtPosition(
|
|
1692
|
+
bufferEnd: number,
|
|
1693
|
+
end: number,
|
|
1694
|
+
levelDetails: LevelDetails,
|
|
1695
|
+
): MediaFragment | null {
|
|
1696
|
+
const { config } = this;
|
|
1697
|
+
let { fragPrevious } = this;
|
|
1698
|
+
let { fragments, endSN } = levelDetails;
|
|
1699
|
+
const { fragmentHint } = levelDetails;
|
|
1700
|
+
const { maxFragLookUpTolerance } = config;
|
|
1701
|
+
const partList = levelDetails.partList;
|
|
1702
|
+
|
|
1703
|
+
const loadingParts = !!(
|
|
1704
|
+
this.loadingParts &&
|
|
1705
|
+
partList?.length &&
|
|
1706
|
+
fragmentHint
|
|
1707
|
+
);
|
|
1708
|
+
if (
|
|
1709
|
+
loadingParts &&
|
|
1710
|
+
!this.bitrateTest &&
|
|
1711
|
+
partList[partList.length - 1].fragment.sn === fragmentHint.sn
|
|
1712
|
+
) {
|
|
1713
|
+
// Include incomplete fragment with parts at end
|
|
1714
|
+
fragments = fragments.concat(fragmentHint);
|
|
1715
|
+
endSN = fragmentHint.sn;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
let frag: MediaFragment | null;
|
|
1719
|
+
if (bufferEnd < end) {
|
|
1720
|
+
const backwardSeek = bufferEnd < this.lastCurrentTime;
|
|
1721
|
+
const lookupTolerance =
|
|
1722
|
+
backwardSeek ||
|
|
1723
|
+
bufferEnd > end - maxFragLookUpTolerance ||
|
|
1724
|
+
this.media?.paused ||
|
|
1725
|
+
!this.startFragRequested
|
|
1726
|
+
? 0
|
|
1727
|
+
: maxFragLookUpTolerance;
|
|
1728
|
+
// Remove the tolerance if it would put the bufferEnd past the actual end of stream
|
|
1729
|
+
// Uses buffer and sequence number to calculate switch segment (required if using EXT-X-DISCONTINUITY-SEQUENCE)
|
|
1730
|
+
frag = findFragmentByPTS(
|
|
1731
|
+
fragPrevious,
|
|
1732
|
+
fragments,
|
|
1733
|
+
bufferEnd,
|
|
1734
|
+
lookupTolerance,
|
|
1735
|
+
);
|
|
1736
|
+
} else {
|
|
1737
|
+
// reach end of playlist
|
|
1738
|
+
frag = fragments[fragments.length - 1];
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
if (frag) {
|
|
1742
|
+
const curSNIdx = frag.sn - levelDetails.startSN;
|
|
1743
|
+
// Move fragPrevious forward to support forcing the next fragment to load
|
|
1744
|
+
// when the buffer catches up to a previously buffered range.
|
|
1745
|
+
const fragState = this.fragmentTracker.getState(frag);
|
|
1746
|
+
if (
|
|
1747
|
+
fragState === FragmentState.OK ||
|
|
1748
|
+
(fragState === FragmentState.PARTIAL && frag.gap)
|
|
1749
|
+
) {
|
|
1750
|
+
fragPrevious = frag;
|
|
1751
|
+
}
|
|
1752
|
+
if (
|
|
1753
|
+
fragPrevious &&
|
|
1754
|
+
frag.sn === fragPrevious.sn &&
|
|
1755
|
+
(!loadingParts ||
|
|
1756
|
+
partList[0].fragment.sn > frag.sn ||
|
|
1757
|
+
!levelDetails.live)
|
|
1758
|
+
) {
|
|
1759
|
+
// Force the next fragment to load if the previous one was already selected. This can occasionally happen with
|
|
1760
|
+
// non-uniform fragment durations
|
|
1761
|
+
const sameLevel = frag.level === fragPrevious.level;
|
|
1762
|
+
if (sameLevel) {
|
|
1763
|
+
const nextFrag = fragments[curSNIdx + 1];
|
|
1764
|
+
if (
|
|
1765
|
+
frag.sn < endSN &&
|
|
1766
|
+
this.fragmentTracker.getState(nextFrag) !== FragmentState.OK
|
|
1767
|
+
) {
|
|
1768
|
+
frag = nextFrag;
|
|
1769
|
+
} else {
|
|
1770
|
+
frag = null;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
return frag;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
protected alignPlaylists(
|
|
1779
|
+
details: LevelDetails,
|
|
1780
|
+
previousDetails: LevelDetails | undefined,
|
|
1781
|
+
switchDetails: LevelDetails | undefined,
|
|
1782
|
+
): number {
|
|
1783
|
+
// TODO: If not for `shouldAlignOnDiscontinuities` requiring fragPrevious.cc,
|
|
1784
|
+
// this could all go in level-helper mergeDetails()
|
|
1785
|
+
const length = details.fragments.length;
|
|
1786
|
+
if (!length) {
|
|
1787
|
+
this.warn(`No fragments in live playlist`);
|
|
1788
|
+
return 0;
|
|
1789
|
+
}
|
|
1790
|
+
const slidingStart = details.fragmentStart;
|
|
1791
|
+
const firstLevelLoad = !previousDetails;
|
|
1792
|
+
const aligned = details.alignedSliding && Number.isFinite(slidingStart);
|
|
1793
|
+
if (firstLevelLoad || (!aligned && !slidingStart)) {
|
|
1794
|
+
alignStream(switchDetails, details, this);
|
|
1795
|
+
const alignedSlidingStart = details.fragmentStart;
|
|
1796
|
+
this.log(
|
|
1797
|
+
`Live playlist sliding: ${alignedSlidingStart.toFixed(2)} start-sn: ${
|
|
1798
|
+
previousDetails ? previousDetails.startSN : 'na'
|
|
1799
|
+
}->${details.startSN} fragments: ${length}`,
|
|
1800
|
+
);
|
|
1801
|
+
return alignedSlidingStart;
|
|
1802
|
+
}
|
|
1803
|
+
return slidingStart;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
protected waitForCdnTuneIn(details: LevelDetails) {
|
|
1807
|
+
// Wait for Low-Latency CDN Tune-in to get an updated playlist
|
|
1808
|
+
const advancePartLimit = 3;
|
|
1809
|
+
return (
|
|
1810
|
+
details.live &&
|
|
1811
|
+
details.canBlockReload &&
|
|
1812
|
+
details.partTarget &&
|
|
1813
|
+
details.tuneInGoal >
|
|
1814
|
+
Math.max(details.partHoldBack, details.partTarget * advancePartLimit)
|
|
1815
|
+
);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
protected setStartPosition(details: LevelDetails, sliding: number) {
|
|
1819
|
+
// compute start position if set to -1. use it straight away if value is defined
|
|
1820
|
+
let startPosition = this.startPosition;
|
|
1821
|
+
if (startPosition < sliding) {
|
|
1822
|
+
startPosition = -1;
|
|
1823
|
+
}
|
|
1824
|
+
const timelineOffset = this.timelineOffset;
|
|
1825
|
+
if (startPosition === -1) {
|
|
1826
|
+
// Use Playlist EXT-X-START:TIME-OFFSET when set
|
|
1827
|
+
// Prioritize Multivariant Playlist offset so that main, audio, and subtitle stream-controller start times match
|
|
1828
|
+
const offsetInMultivariantPlaylist = this.startTimeOffset !== null;
|
|
1829
|
+
const startTimeOffset = offsetInMultivariantPlaylist
|
|
1830
|
+
? this.startTimeOffset
|
|
1831
|
+
: details.startTimeOffset;
|
|
1832
|
+
if (startTimeOffset !== null && Number.isFinite(startTimeOffset)) {
|
|
1833
|
+
startPosition = sliding + startTimeOffset;
|
|
1834
|
+
if (startTimeOffset < 0) {
|
|
1835
|
+
startPosition += details.edge;
|
|
1836
|
+
}
|
|
1837
|
+
startPosition = Math.min(
|
|
1838
|
+
Math.max(sliding, startPosition),
|
|
1839
|
+
sliding + details.totalduration,
|
|
1840
|
+
);
|
|
1841
|
+
this.log(
|
|
1842
|
+
`Setting startPosition to ${startPosition} for start time offset ${startTimeOffset} found in ${
|
|
1843
|
+
offsetInMultivariantPlaylist ? 'multivariant' : 'media'
|
|
1844
|
+
} playlist`,
|
|
1845
|
+
);
|
|
1846
|
+
this.startPosition = startPosition;
|
|
1847
|
+
} else if (details.live) {
|
|
1848
|
+
// Leave this.startPosition at -1, so that we can use `getInitialLiveFragment` logic when startPosition has
|
|
1849
|
+
// not been specified via the config or an as an argument to startLoad (#3736).
|
|
1850
|
+
startPosition = this.hls.liveSyncPosition || sliding;
|
|
1851
|
+
this.log(
|
|
1852
|
+
`Setting startPosition to -1 to start at live edge ${startPosition}`,
|
|
1853
|
+
);
|
|
1854
|
+
this.startPosition = -1;
|
|
1855
|
+
} else {
|
|
1856
|
+
this.log(`setting startPosition to 0 by default`);
|
|
1857
|
+
this.startPosition = startPosition = 0;
|
|
1858
|
+
}
|
|
1859
|
+
this.lastCurrentTime = startPosition + timelineOffset;
|
|
1860
|
+
}
|
|
1861
|
+
this.nextLoadPosition = startPosition + timelineOffset;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
protected getLoadPosition(): number {
|
|
1865
|
+
const { media } = this;
|
|
1866
|
+
// if we have not yet loaded any fragment, start loading from start position
|
|
1867
|
+
let pos = 0;
|
|
1868
|
+
if (this.hls?.hasEnoughToStart && media) {
|
|
1869
|
+
pos = media.currentTime;
|
|
1870
|
+
} else if (this.nextLoadPosition >= 0) {
|
|
1871
|
+
pos = this.nextLoadPosition;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
return pos;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
private handleFragLoadAborted(frag: Fragment, part: Part | null | undefined) {
|
|
1878
|
+
if (
|
|
1879
|
+
this.transmuxer &&
|
|
1880
|
+
frag.type === this.playlistType &&
|
|
1881
|
+
isMediaFragment(frag) &&
|
|
1882
|
+
frag.stats.aborted
|
|
1883
|
+
) {
|
|
1884
|
+
this.log(
|
|
1885
|
+
`Fragment ${frag.sn}${part ? ' part ' + part.index : ''} of ${this.playlistLabel()} ${
|
|
1886
|
+
frag.level
|
|
1887
|
+
} was aborted`,
|
|
1888
|
+
);
|
|
1889
|
+
this.resetFragmentLoading(frag);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
protected resetFragmentLoading(frag: Fragment) {
|
|
1894
|
+
if (
|
|
1895
|
+
!this.fragCurrent ||
|
|
1896
|
+
(!this.fragContextChanged(frag) &&
|
|
1897
|
+
this.state !== State.FRAG_LOADING_WAITING_RETRY)
|
|
1898
|
+
) {
|
|
1899
|
+
this.state = State.IDLE;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
protected onFragmentOrKeyLoadError(
|
|
1904
|
+
filterType: PlaylistLevelType,
|
|
1905
|
+
data: ErrorData,
|
|
1906
|
+
) {
|
|
1907
|
+
if (data.chunkMeta && !data.frag) {
|
|
1908
|
+
const context = this.getCurrentContext(data.chunkMeta);
|
|
1909
|
+
if (context) {
|
|
1910
|
+
data.frag = context.frag;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
const frag = data.frag;
|
|
1914
|
+
// Handle frag error related to caller's filterType
|
|
1915
|
+
if (!frag || !this.levels || frag.type !== filterType) {
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
if (this.fragContextChanged(frag)) {
|
|
1919
|
+
this.warn(
|
|
1920
|
+
`Frag load error must match current frag to retry ${frag.url} > ${this.fragCurrent?.url}`,
|
|
1921
|
+
);
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const gapTagEncountered = data.details === ErrorDetails.FRAG_GAP;
|
|
1925
|
+
if (gapTagEncountered) {
|
|
1926
|
+
this.fragmentTracker.fragBuffered(frag as MediaFragment, true);
|
|
1927
|
+
}
|
|
1928
|
+
// keep retrying until the limit will be reached
|
|
1929
|
+
const errorAction = data.errorAction;
|
|
1930
|
+
if (!errorAction) {
|
|
1931
|
+
this.state = State.ERROR;
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
const { action, flags, retryCount = 0, retryConfig } = errorAction;
|
|
1935
|
+
const couldRetry = !!retryConfig;
|
|
1936
|
+
const retry = couldRetry && action === NetworkErrorAction.RetryRequest;
|
|
1937
|
+
const noAlternate =
|
|
1938
|
+
couldRetry &&
|
|
1939
|
+
!errorAction.resolved &&
|
|
1940
|
+
flags === ErrorActionFlags.MoveAllAlternatesMatchingHost;
|
|
1941
|
+
const live = this.hls.latestLevelDetails?.live;
|
|
1942
|
+
if (
|
|
1943
|
+
!retry &&
|
|
1944
|
+
noAlternate &&
|
|
1945
|
+
isMediaFragment(frag) &&
|
|
1946
|
+
!frag.endList &&
|
|
1947
|
+
live &&
|
|
1948
|
+
!isUnusableKeyError(data)
|
|
1949
|
+
) {
|
|
1950
|
+
this.resetFragmentErrors(filterType);
|
|
1951
|
+
this.treatAsGap(frag);
|
|
1952
|
+
errorAction.resolved = true;
|
|
1953
|
+
} else if ((retry || noAlternate) && retryCount < retryConfig.maxNumRetry) {
|
|
1954
|
+
const offlineStatus = offlineHttpStatus(data.response?.code);
|
|
1955
|
+
const delay = getRetryDelay(retryConfig, retryCount);
|
|
1956
|
+
this.resetStartWhenNotLoaded();
|
|
1957
|
+
this.retryDate = self.performance.now() + delay;
|
|
1958
|
+
this.state = State.FRAG_LOADING_WAITING_RETRY;
|
|
1959
|
+
errorAction.resolved = true;
|
|
1960
|
+
if (offlineStatus) {
|
|
1961
|
+
this.log(`Waiting for connection (offline)`);
|
|
1962
|
+
this.retryDate = Infinity;
|
|
1963
|
+
data.reason = 'offline';
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
this.warn(
|
|
1967
|
+
`Fragment ${frag.sn} of ${filterType} ${frag.level} errored with ${
|
|
1968
|
+
data.details
|
|
1969
|
+
}, retrying loading ${retryCount + 1}/${
|
|
1970
|
+
retryConfig.maxNumRetry
|
|
1971
|
+
} in ${delay}ms`,
|
|
1972
|
+
);
|
|
1973
|
+
} else if (retryConfig) {
|
|
1974
|
+
this.resetFragmentErrors(filterType);
|
|
1975
|
+
if (retryCount < retryConfig.maxNumRetry) {
|
|
1976
|
+
// Network retry is skipped when level switch is preferred
|
|
1977
|
+
if (
|
|
1978
|
+
!gapTagEncountered &&
|
|
1979
|
+
action !== NetworkErrorAction.RemoveAlternatePermanently
|
|
1980
|
+
) {
|
|
1981
|
+
errorAction.resolved = true;
|
|
1982
|
+
}
|
|
1983
|
+
} else {
|
|
1984
|
+
this.warn(
|
|
1985
|
+
`${data.details} reached or exceeded max retry (${retryCount})`,
|
|
1986
|
+
);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
} else if (action === NetworkErrorAction.SendAlternateToPenaltyBox) {
|
|
1990
|
+
this.state = State.WAITING_LEVEL;
|
|
1991
|
+
} else {
|
|
1992
|
+
this.state = State.ERROR;
|
|
1993
|
+
}
|
|
1994
|
+
// Perform next async tick sooner to speed up error action resolution
|
|
1995
|
+
this.tickImmediate();
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
protected checkRetryDate() {
|
|
1999
|
+
const now = self.performance.now();
|
|
2000
|
+
const retryDate = this.retryDate;
|
|
2001
|
+
// if current time is gt than retryDate, or if media seeking let's switch to IDLE state to retry loading
|
|
2002
|
+
const waitingForConnection = retryDate === Infinity;
|
|
2003
|
+
if (
|
|
2004
|
+
!retryDate ||
|
|
2005
|
+
now >= retryDate ||
|
|
2006
|
+
(waitingForConnection && !offlineHttpStatus(0))
|
|
2007
|
+
) {
|
|
2008
|
+
if (waitingForConnection) {
|
|
2009
|
+
this.log(`Connection restored (online)`);
|
|
2010
|
+
}
|
|
2011
|
+
this.resetStartWhenNotLoaded();
|
|
2012
|
+
this.state = State.IDLE;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
protected reduceLengthAndFlushBuffer(data: ErrorData): boolean {
|
|
2017
|
+
// if in appending state
|
|
2018
|
+
if (this.state === State.PARSING || this.state === State.PARSED) {
|
|
2019
|
+
const frag = data.frag;
|
|
2020
|
+
const playlistType = data.parent as PlaylistLevelType;
|
|
2021
|
+
const bufferedInfo = this.getFwdBufferInfo(
|
|
2022
|
+
this.mediaBuffer,
|
|
2023
|
+
playlistType,
|
|
2024
|
+
);
|
|
2025
|
+
// 0.5 : tolerance needed as some browsers stalls playback before reaching buffered end
|
|
2026
|
+
// reduce max buf len if current position is buffered
|
|
2027
|
+
const buffered = bufferedInfo && bufferedInfo.len > 0.5;
|
|
2028
|
+
if (buffered) {
|
|
2029
|
+
this.reduceMaxBufferLength(bufferedInfo.len, frag?.duration || 10);
|
|
2030
|
+
}
|
|
2031
|
+
const flushBuffer = !buffered;
|
|
2032
|
+
if (flushBuffer) {
|
|
2033
|
+
// current position is not buffered, but browser is still complaining about buffer full error
|
|
2034
|
+
// this happens on IE/Edge, refer to https://github.com/video-dev/hls.js/pull/708
|
|
2035
|
+
// in that case flush the whole audio buffer to recover
|
|
2036
|
+
this.warn(
|
|
2037
|
+
`Buffer full error while media.currentTime (${this.getLoadPosition()}) is not buffered, flush ${playlistType} buffer`,
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
if (frag) {
|
|
2041
|
+
this.fragmentTracker.removeFragment(frag);
|
|
2042
|
+
this.nextLoadPosition = frag.start;
|
|
2043
|
+
}
|
|
2044
|
+
this.resetLoadingState();
|
|
2045
|
+
return flushBuffer;
|
|
2046
|
+
}
|
|
2047
|
+
return false;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
protected resetFragmentErrors(filterType: PlaylistLevelType) {
|
|
2051
|
+
if (filterType === PlaylistLevelType.AUDIO) {
|
|
2052
|
+
// Reset current fragment since audio track audio is essential and may not have a fail-over track
|
|
2053
|
+
this.fragCurrent = null;
|
|
2054
|
+
}
|
|
2055
|
+
// Fragment errors that result in a level switch or redundant fail-over
|
|
2056
|
+
// should reset the stream controller state to idle
|
|
2057
|
+
if (!this.hls.hasEnoughToStart) {
|
|
2058
|
+
this.startFragRequested = false;
|
|
2059
|
+
}
|
|
2060
|
+
if (this.state !== State.STOPPED) {
|
|
2061
|
+
this.state = State.IDLE;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
protected afterBufferFlushed(
|
|
2066
|
+
media: Bufferable,
|
|
2067
|
+
bufferType: SourceBufferName,
|
|
2068
|
+
) {
|
|
2069
|
+
if (!media) {
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
// After successful buffer flushing, filter flushed fragments from bufferedFrags use mediaBuffered instead of media
|
|
2073
|
+
// (so that we will check against video.buffered ranges in case of alt audio track)
|
|
2074
|
+
const bufferedTimeRanges = BufferHelper.getBuffered(media);
|
|
2075
|
+
this.fragmentTracker.detectEvictedFragments(
|
|
2076
|
+
bufferType,
|
|
2077
|
+
bufferedTimeRanges,
|
|
2078
|
+
this.playlistType,
|
|
2079
|
+
);
|
|
2080
|
+
if (this.state === State.ENDED) {
|
|
2081
|
+
this.resetLoadingState();
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
protected resetLoadingState() {
|
|
2086
|
+
this.log('Reset loading state');
|
|
2087
|
+
this.fragCurrent = null;
|
|
2088
|
+
this.fragPrevious = null;
|
|
2089
|
+
if (this.state !== State.STOPPED) {
|
|
2090
|
+
this.state = State.IDLE;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
private resetStartWhenNotLoaded() {
|
|
2095
|
+
// if loadedmetadata is not set, it means that first frag request failed
|
|
2096
|
+
// in that case, reset startFragRequested flag
|
|
2097
|
+
if (!this.hls.hasEnoughToStart) {
|
|
2098
|
+
this.startFragRequested = false;
|
|
2099
|
+
const level = this.levelLastLoaded;
|
|
2100
|
+
const details = level ? level.details : null;
|
|
2101
|
+
if (details?.live) {
|
|
2102
|
+
// Update the start position and return to IDLE to recover live start
|
|
2103
|
+
this.log(`resetting startPosition for live start`);
|
|
2104
|
+
this.startPosition = -1;
|
|
2105
|
+
this.setStartPosition(details, details.fragmentStart);
|
|
2106
|
+
this.resetLoadingState();
|
|
2107
|
+
} else {
|
|
2108
|
+
this.nextLoadPosition = this.startPosition;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
protected resetWhenMissingContext(chunkMeta: ChunkMetadata | Fragment) {
|
|
2114
|
+
this.log(
|
|
2115
|
+
`Loading context changed while buffering sn ${chunkMeta.sn} of ${this.playlistLabel()} ${chunkMeta.level === -1 ? '<removed>' : chunkMeta.level}. This chunk will not be buffered.`,
|
|
2116
|
+
);
|
|
2117
|
+
this.removeUnbufferedFrags();
|
|
2118
|
+
this.resetStartWhenNotLoaded();
|
|
2119
|
+
this.resetLoadingState();
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
protected removeUnbufferedFrags(start: number = 0) {
|
|
2123
|
+
this.fragmentTracker.removeFragmentsInRange(
|
|
2124
|
+
start,
|
|
2125
|
+
Infinity,
|
|
2126
|
+
this.playlistType,
|
|
2127
|
+
false,
|
|
2128
|
+
true,
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
private updateLevelTiming(
|
|
2133
|
+
frag: MediaFragment,
|
|
2134
|
+
part: Part | null,
|
|
2135
|
+
level: Level,
|
|
2136
|
+
partial: boolean,
|
|
2137
|
+
) {
|
|
2138
|
+
const details = level.details;
|
|
2139
|
+
if (!details) {
|
|
2140
|
+
this.warn('level.details undefined');
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const parsed = Object.keys(frag.elementaryStreams).reduce(
|
|
2144
|
+
(result, type) => {
|
|
2145
|
+
const info = frag.elementaryStreams[type];
|
|
2146
|
+
if (info) {
|
|
2147
|
+
const parsedDuration = info.endPTS - info.startPTS;
|
|
2148
|
+
if (parsedDuration <= 0) {
|
|
2149
|
+
// Destroy the transmuxer after it's next time offset failed to advance because duration was <= 0.
|
|
2150
|
+
// The new transmuxer will be configured with a time offset matching the next fragment start,
|
|
2151
|
+
// preventing the timeline from shifting.
|
|
2152
|
+
this.warn(
|
|
2153
|
+
`Could not parse fragment ${frag.sn} ${type} duration reliably (${parsedDuration})`,
|
|
2154
|
+
);
|
|
2155
|
+
return result || false;
|
|
2156
|
+
}
|
|
2157
|
+
const drift = partial
|
|
2158
|
+
? 0
|
|
2159
|
+
: updateFragPTSDTS(
|
|
2160
|
+
details,
|
|
2161
|
+
frag,
|
|
2162
|
+
info.startPTS,
|
|
2163
|
+
info.endPTS,
|
|
2164
|
+
info.startDTS,
|
|
2165
|
+
info.endDTS,
|
|
2166
|
+
this,
|
|
2167
|
+
);
|
|
2168
|
+
this.hls.trigger(Events.LEVEL_PTS_UPDATED, {
|
|
2169
|
+
details,
|
|
2170
|
+
level,
|
|
2171
|
+
drift,
|
|
2172
|
+
type,
|
|
2173
|
+
frag,
|
|
2174
|
+
start: info.startPTS,
|
|
2175
|
+
end: info.endPTS,
|
|
2176
|
+
});
|
|
2177
|
+
return true;
|
|
2178
|
+
}
|
|
2179
|
+
return result;
|
|
2180
|
+
},
|
|
2181
|
+
false,
|
|
2182
|
+
);
|
|
2183
|
+
if (!parsed) {
|
|
2184
|
+
const mediaNotFound = this.transmuxer?.error === null;
|
|
2185
|
+
if (
|
|
2186
|
+
level.fragmentError === 0 ||
|
|
2187
|
+
(mediaNotFound && (level.fragmentError < 2 || frag.endList))
|
|
2188
|
+
) {
|
|
2189
|
+
// Mark and track the odd (or last) empty segment as a gap to avoid reloading
|
|
2190
|
+
this.treatAsGap(frag, level);
|
|
2191
|
+
}
|
|
2192
|
+
if (mediaNotFound) {
|
|
2193
|
+
const error = new Error(
|
|
2194
|
+
`Found no media in fragment ${frag.sn} of ${this.playlistLabel()} ${frag.level} resetting transmuxer to fallback to playlist timing`,
|
|
2195
|
+
);
|
|
2196
|
+
this.warn(error.message);
|
|
2197
|
+
this.hls.trigger(Events.ERROR, {
|
|
2198
|
+
type: ErrorTypes.MEDIA_ERROR,
|
|
2199
|
+
details: ErrorDetails.FRAG_PARSING_ERROR,
|
|
2200
|
+
fatal: false,
|
|
2201
|
+
error,
|
|
2202
|
+
frag,
|
|
2203
|
+
reason: `Found no media in msn ${frag.sn} of ${this.playlistLabel()} "${level.url}"`,
|
|
2204
|
+
});
|
|
2205
|
+
if (!this.hls) {
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
this.resetTransmuxer();
|
|
2209
|
+
}
|
|
2210
|
+
// For this error fallthrough. Marking parsed will allow advancing to next fragment.
|
|
2211
|
+
}
|
|
2212
|
+
this.state = State.PARSED;
|
|
2213
|
+
this.log(
|
|
2214
|
+
`Parsed ${frag.type} sn: ${frag.sn}${
|
|
2215
|
+
part ? ' part: ' + part.index : ''
|
|
2216
|
+
} of ${this.fragInfo(frag, false, part)})`,
|
|
2217
|
+
);
|
|
2218
|
+
this.hls.trigger(Events.FRAG_PARSED, { frag, part });
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
private playlistLabel() {
|
|
2222
|
+
return this.playlistType === PlaylistLevelType.MAIN ? 'level' : 'track';
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
private fragInfo(
|
|
2226
|
+
frag: Fragment,
|
|
2227
|
+
pts: boolean = true,
|
|
2228
|
+
part?: Part | null,
|
|
2229
|
+
): string {
|
|
2230
|
+
return `${this.playlistLabel()} ${frag.level} (${part ? 'part' : 'frag'}:[${((pts && !part ? frag.startPTS : (part || frag).start) ?? NaN).toFixed(3)}-${(
|
|
2231
|
+
(pts && !part ? frag.endPTS : (part || frag).end) ?? NaN
|
|
2232
|
+
).toFixed(
|
|
2233
|
+
3,
|
|
2234
|
+
)}]${part && frag.type === 'main' ? 'INDEPENDENT=' + (part.independent ? 'YES' : 'NO') : ''}`;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
private treatAsGap(frag: MediaFragment, level?: Level) {
|
|
2238
|
+
if (level) {
|
|
2239
|
+
level.fragmentError++;
|
|
2240
|
+
}
|
|
2241
|
+
frag.gap = true;
|
|
2242
|
+
this.fragmentTracker.removeFragment(frag);
|
|
2243
|
+
this.fragmentTracker.fragBuffered(frag, true);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
protected resetTransmuxer() {
|
|
2247
|
+
this.transmuxer?.reset();
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
private isFragmentNearlyDownloaded(fragment: Fragment): boolean {
|
|
2251
|
+
const stats = fragment.loader?.stats;
|
|
2252
|
+
if (!stats) {
|
|
2253
|
+
return false;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
const hasFirstByte = stats.loading.first > 0;
|
|
2257
|
+
const bitsRemaining = stats.total - stats.loaded;
|
|
2258
|
+
const timeToCompleteFragDownload =
|
|
2259
|
+
bitsRemaining /
|
|
2260
|
+
(this.hls.bandwidthEstimate || this.hls.config.abrEwmaDefaultEstimate);
|
|
2261
|
+
|
|
2262
|
+
// Fragment is nearly complete if we have first byte and will complete within 150ms
|
|
2263
|
+
return hasFirstByte && timeToCompleteFragDownload <= 0.15;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
protected recoverWorkerError(data: ErrorData) {
|
|
2267
|
+
if (data.event === 'demuxerWorker') {
|
|
2268
|
+
this.fragmentTracker.removeAllFragments();
|
|
2269
|
+
if (this.transmuxer) {
|
|
2270
|
+
this.transmuxer.destroy();
|
|
2271
|
+
this.transmuxer = null;
|
|
2272
|
+
}
|
|
2273
|
+
this.resetStartWhenNotLoaded();
|
|
2274
|
+
this.resetLoadingState();
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
set state(nextState: (typeof State)[keyof typeof State]) {
|
|
2279
|
+
const previousState = this._state;
|
|
2280
|
+
if (previousState !== nextState) {
|
|
2281
|
+
this._state = nextState;
|
|
2282
|
+
this.log(`${previousState}->${nextState}`);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
get state(): (typeof State)[keyof typeof State] {
|
|
2287
|
+
return this._state;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* Calculate optimal switch point by considering fetch delays and buffer info
|
|
2292
|
+
* to avoid causing playback interruption
|
|
2293
|
+
*/
|
|
2294
|
+
protected calculateOptimalSwitchPoint(
|
|
2295
|
+
nextLevel: Level,
|
|
2296
|
+
bufferInfo: BufferInfo,
|
|
2297
|
+
): { fetchdelay: number; okToFlushForwardBuffer: boolean } {
|
|
2298
|
+
let fetchdelay = 0;
|
|
2299
|
+
const { hls, media, config, levels, playlistType } = this;
|
|
2300
|
+
const levelDetails = this.getLevelDetails();
|
|
2301
|
+
if (media && !media.paused && levels) {
|
|
2302
|
+
const maxBitrate =
|
|
2303
|
+
playlistType === PlaylistLevelType.AUDIO
|
|
2304
|
+
? estimatedAudioBitrate(nextLevel.audioCodec, 128000)
|
|
2305
|
+
: nextLevel.maxBitrate;
|
|
2306
|
+
// add a safety delay of 1s
|
|
2307
|
+
const ttfbSec = 1 + hls.ttfbEstimate / 1000;
|
|
2308
|
+
const bandwidth = hls.bandwidthEstimate * config.abrBandWidthUpFactor;
|
|
2309
|
+
const fragDuration =
|
|
2310
|
+
(levelDetails &&
|
|
2311
|
+
(this.loadingParts
|
|
2312
|
+
? levelDetails.partTarget
|
|
2313
|
+
: levelDetails.averagetargetduration)) ||
|
|
2314
|
+
this.fragCurrent?.duration ||
|
|
2315
|
+
6;
|
|
2316
|
+
fetchdelay = ttfbSec + (maxBitrate * fragDuration) / bandwidth;
|
|
2317
|
+
if (!nextLevel.details) {
|
|
2318
|
+
fetchdelay += ttfbSec;
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const currentTime = this.media?.currentTime || this.getLoadPosition();
|
|
2323
|
+
// Do not flush in live stream with low buffer
|
|
2324
|
+
const okToFlushForwardBuffer =
|
|
2325
|
+
!levelDetails?.live || bufferInfo.end - currentTime > fetchdelay * 1.5;
|
|
2326
|
+
|
|
2327
|
+
return { fetchdelay, okToFlushForwardBuffer };
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
/**
|
|
2331
|
+
* Generic track switching scheduler that prevents buffering interruptions
|
|
2332
|
+
* by finding optimal flush points in the buffer
|
|
2333
|
+
* This method can be overridden by subclasses with specific implementation details
|
|
2334
|
+
*/
|
|
2335
|
+
protected scheduleTrackSwitch(
|
|
2336
|
+
bufferInfo: BufferInfo,
|
|
2337
|
+
fetchdelay: number,
|
|
2338
|
+
okToFlushForwardBuffer: boolean,
|
|
2339
|
+
): void {
|
|
2340
|
+
const { media, playlistType } = this;
|
|
2341
|
+
if (!media || !bufferInfo) {
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// find buffer range that will be reached once new fragment will be fetched
|
|
2346
|
+
const bufferedFrag = okToFlushForwardBuffer
|
|
2347
|
+
? this.getBufferedFrag(this.getLoadPosition() + fetchdelay)
|
|
2348
|
+
: null;
|
|
2349
|
+
|
|
2350
|
+
if (bufferedFrag) {
|
|
2351
|
+
// we can flush buffer range following this one without stalling playback
|
|
2352
|
+
const nextBufferedFrag = this.followingBufferedFrag(bufferedFrag);
|
|
2353
|
+
if (nextBufferedFrag) {
|
|
2354
|
+
// if we are here, we can also cancel any loading/demuxing in progress, as they are useless
|
|
2355
|
+
this.abortCurrentFrag();
|
|
2356
|
+
// start flush position is in next buffered frag. Leave some padding for non-independent segments and smoother playback.
|
|
2357
|
+
const maxStart = nextBufferedFrag.maxStartPTS
|
|
2358
|
+
? nextBufferedFrag.maxStartPTS
|
|
2359
|
+
: nextBufferedFrag.start;
|
|
2360
|
+
const fragDuration = nextBufferedFrag.duration;
|
|
2361
|
+
const startPts = Math.max(
|
|
2362
|
+
bufferedFrag.end,
|
|
2363
|
+
maxStart +
|
|
2364
|
+
Math.min(
|
|
2365
|
+
Math.max(
|
|
2366
|
+
fragDuration - this.config.maxFragLookUpTolerance,
|
|
2367
|
+
fragDuration * (this.couldBacktrack ? 0.5 : 0.125),
|
|
2368
|
+
),
|
|
2369
|
+
fragDuration * (this.couldBacktrack ? 0.75 : 0.25),
|
|
2370
|
+
),
|
|
2371
|
+
);
|
|
2372
|
+
const bufferType =
|
|
2373
|
+
playlistType === PlaylistLevelType.MAIN ? null : 'audio';
|
|
2374
|
+
// Flush forward buffer from next buffered frag start to infinity
|
|
2375
|
+
this.flushMainBuffer(startPts, Number.POSITIVE_INFINITY, bufferType);
|
|
2376
|
+
// Flush back buffer (excluding current fragment)
|
|
2377
|
+
this.cleanupBackBuffer();
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
/**
|
|
2383
|
+
* Handle back-buffer cleanup during track switching
|
|
2384
|
+
*/
|
|
2385
|
+
protected cleanupBackBuffer(): void {
|
|
2386
|
+
const { media, playlistType } = this;
|
|
2387
|
+
if (!media) {
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// remove back-buffer
|
|
2392
|
+
const fragPlayingCurrent = this.getAppendedFrag(this.getLoadPosition());
|
|
2393
|
+
if (fragPlayingCurrent && fragPlayingCurrent.start > 1) {
|
|
2394
|
+
const isAudio = playlistType === PlaylistLevelType.AUDIO;
|
|
2395
|
+
// flush buffer preceding current fragment (flush until current fragment start offset)
|
|
2396
|
+
// minus 1s to avoid video freezing, that could happen if we flush keyframe of current video ...
|
|
2397
|
+
this.flushMainBuffer(
|
|
2398
|
+
0,
|
|
2399
|
+
fragPlayingCurrent.start - (isAudio ? 0 : 1),
|
|
2400
|
+
isAudio ? 'audio' : null,
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
/**
|
|
2406
|
+
* Gets buffered fragment at the specified position
|
|
2407
|
+
*/
|
|
2408
|
+
protected getBufferedFrag(position: number): Fragment | null {
|
|
2409
|
+
return this.fragmentTracker.getBufferedFrag(position, this.playlistType);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/**
|
|
2413
|
+
* Gets the next buffered fragment following the given fragment
|
|
2414
|
+
*/
|
|
2415
|
+
protected followingBufferedFrag(frag: Fragment | null): Fragment | null {
|
|
2416
|
+
if (frag) {
|
|
2417
|
+
// try to get range of next fragment (500ms after this range)
|
|
2418
|
+
return this.getBufferedFrag(frag.end + 0.5);
|
|
2419
|
+
}
|
|
2420
|
+
return null;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/**
|
|
2424
|
+
* Aborts the current fragment loading and resets state
|
|
2425
|
+
* Can be overridden by subclasses for specific behavior
|
|
2426
|
+
*/
|
|
2427
|
+
protected abortCurrentFrag(): void {
|
|
2428
|
+
const fragCurrent = this.fragCurrent;
|
|
2429
|
+
this.fragCurrent = null;
|
|
2430
|
+
if (fragCurrent) {
|
|
2431
|
+
fragCurrent.abortRequests();
|
|
2432
|
+
this.fragmentTracker.removeFragment(fragCurrent);
|
|
2433
|
+
}
|
|
2434
|
+
switch (this.state) {
|
|
2435
|
+
case State.KEY_LOADING:
|
|
2436
|
+
case State.FRAG_LOADING:
|
|
2437
|
+
case State.FRAG_LOADING_WAITING_RETRY:
|
|
2438
|
+
case State.PARSING:
|
|
2439
|
+
case State.PARSED:
|
|
2440
|
+
this.state = State.IDLE;
|
|
2441
|
+
break;
|
|
2442
|
+
}
|
|
2443
|
+
this.nextLoadPosition = this.getLoadPosition();
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
protected checkFragmentChanged(): boolean {
|
|
2447
|
+
const video = this.media;
|
|
2448
|
+
let fragPlayingCurrent: Fragment | null = null;
|
|
2449
|
+
if (video && video.readyState > 1 && video.seeking === false) {
|
|
2450
|
+
const currentTime = video.currentTime;
|
|
2451
|
+
/* if video element is in seeked state, currentTime can only increase.
|
|
2452
|
+
(assuming that playback rate is positive ...)
|
|
2453
|
+
As sometimes currentTime jumps back to zero after a
|
|
2454
|
+
media decode error, check this, to avoid seeking back to
|
|
2455
|
+
wrong position after a media decode error
|
|
2456
|
+
*/
|
|
2457
|
+
|
|
2458
|
+
if (BufferHelper.isBuffered(video, currentTime)) {
|
|
2459
|
+
fragPlayingCurrent = this.getAppendedFrag(currentTime);
|
|
2460
|
+
} else if (BufferHelper.isBuffered(video, currentTime + 0.1)) {
|
|
2461
|
+
/* ensure that FRAG_CHANGED event is triggered at startup,
|
|
2462
|
+
when first video frame is displayed and playback is paused.
|
|
2463
|
+
add a tolerance of 100ms, in case current position is not buffered,
|
|
2464
|
+
check if current pos+100ms is buffered and use that buffer range
|
|
2465
|
+
for FRAG_CHANGED event reporting */
|
|
2466
|
+
fragPlayingCurrent = this.getAppendedFrag(currentTime + 0.1);
|
|
2467
|
+
}
|
|
2468
|
+
if (fragPlayingCurrent) {
|
|
2469
|
+
this.backtrackFragment = undefined;
|
|
2470
|
+
const fragPlaying = this.fragPlaying;
|
|
2471
|
+
const fragCurrentLevel = fragPlayingCurrent.level;
|
|
2472
|
+
if (
|
|
2473
|
+
!fragPlaying ||
|
|
2474
|
+
fragPlayingCurrent.sn !== fragPlaying.sn ||
|
|
2475
|
+
fragPlaying.level !== fragCurrentLevel
|
|
2476
|
+
) {
|
|
2477
|
+
this.fragPlaying = fragPlayingCurrent;
|
|
2478
|
+
return true;
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
return false;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
protected getBufferOutput(): Bufferable | null {
|
|
2486
|
+
return null;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/**
|
|
2490
|
+
* try to switch ASAP without breaking video playback:
|
|
2491
|
+
* in order to ensure smooth but quick level switching,
|
|
2492
|
+
* we need to find the next flushable buffer range
|
|
2493
|
+
* we should take into account new segment fetch time
|
|
2494
|
+
*/
|
|
2495
|
+
public nextLevelSwitch() {
|
|
2496
|
+
const { levels, media, hls, config, playlistType } = this;
|
|
2497
|
+
// ensure that media is defined and that metadata are available (to retrieve currentTime)
|
|
2498
|
+
if (media?.readyState && levels && hls && config) {
|
|
2499
|
+
const bufferOutput = this.getBufferOutput();
|
|
2500
|
+
const bufferInfo = this.getFwdBufferInfo(bufferOutput, playlistType);
|
|
2501
|
+
if (!bufferInfo) {
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
const nextLevelId =
|
|
2506
|
+
playlistType === PlaylistLevelType.AUDIO
|
|
2507
|
+
? hls.nextAudioTrack
|
|
2508
|
+
: hls.nextLoadLevel;
|
|
2509
|
+
const nextLevel = levels[nextLevelId];
|
|
2510
|
+
|
|
2511
|
+
const { fetchdelay, okToFlushForwardBuffer } =
|
|
2512
|
+
this.calculateOptimalSwitchPoint(nextLevel, bufferInfo);
|
|
2513
|
+
|
|
2514
|
+
this.scheduleTrackSwitch(bufferInfo, fetchdelay, okToFlushForwardBuffer);
|
|
2515
|
+
}
|
|
2516
|
+
this.tickImmediate();
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function interstitialsEnabled(config: HlsConfig): boolean {
|
|
2521
|
+
return (
|
|
2522
|
+
__USE_INTERSTITIALS__ &&
|
|
2523
|
+
!!config.interstitialsController &&
|
|
2524
|
+
config.enableInterstitialPlayback !== false
|
|
2525
|
+
);
|
|
2526
|
+
}
|