1
0
Fork 0
mirror of https://github.com/DanielnetoDotCom/YouPHPTube synced 2025-10-05 10:49:36 +02:00
This commit is contained in:
DanieL 2023-02-13 14:41:08 -03:00
parent 64c36d9f4e
commit 0d0338876d
1197 changed files with 121461 additions and 179724 deletions

48
node_modules/hls.js/src/config.ts generated vendored
View file

@ -7,7 +7,9 @@ import BufferController from './controller/buffer-controller';
import { TimelineController } from './controller/timeline-controller';
import CapLevelController from './controller/cap-level-controller';
import FPSController from './controller/fps-controller';
import EMEController from './controller/eme-controller';
import EMEController, {
MediaKeySessionContext,
} from './controller/eme-controller';
import CMCDController from './controller/cmcd-controller';
import XhrLoader from './utils/xhr-loader';
import FetchLoader, { fetchSupported } from './utils/fetch-loader';
@ -15,8 +17,9 @@ import Cues from './utils/cues';
import { requestMediaKeySystemAccess } from './utils/mediakeys-helper';
import { ILogger, logger } from './utils/logger';
import type Hls from './hls';
import type { CuesInterface } from './utils/cues';
import type { MediaKeyFunc } from './utils/mediakeys-helper';
import type { MediaKeyFunc, KeySystems } from './utils/mediakeys-helper';
import type {
FragmentLoaderContext,
Loader,
@ -57,13 +60,49 @@ export type CMCDControllerConfig = {
export type DRMSystemOptions = {
audioRobustness?: string;
videoRobustness?: string;
audioEncryptionScheme?: string | null;
videoEncryptionScheme?: string | null;
persistentState?: MediaKeysRequirement;
distinctiveIdentifier?: MediaKeysRequirement;
sessionTypes?: string[];
sessionType?: string;
};
export type DRMSystemConfiguration = {
licenseUrl: string;
serverCertificateUrl?: string;
generateRequest?: (
this: Hls,
initDataType: string,
initData: ArrayBuffer | null,
keyContext: MediaKeySessionContext
) =>
| { initDataType: string; initData: ArrayBuffer | null }
| undefined
| never;
};
export type DRMSystemsConfiguration = Partial<
Record<KeySystems, DRMSystemConfiguration>
>;
export type EMEControllerConfig = {
licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void;
licenseResponseCallback?: (xhr: XMLHttpRequest, url: string) => ArrayBuffer;
licenseXhrSetup?: (
this: Hls,
xhr: XMLHttpRequest,
url: string,
keyContext: MediaKeySessionContext,
licenseChallenge: Uint8Array
) => void | Uint8Array | Promise<Uint8Array | void>;
licenseResponseCallback?: (
this: Hls,
xhr: XMLHttpRequest,
url: string,
keyContext: MediaKeySessionContext
) => ArrayBuffer;
emeEnabled: boolean;
widevineLicenseUrl?: string;
drmSystems: DRMSystemsConfiguration;
drmSystemOptions: DRMSystemOptions;
requestMediaKeySystemAccessFunc: MediaKeyFunc | null;
};
@ -283,6 +322,7 @@ export const hlsDefaultConfig: HlsConfig = {
minAutoBitrate: 0, // used by hls
emeEnabled: false, // used by eme-controller
widevineLicenseUrl: undefined, // used by eme-controller
drmSystems: {}, // used by eme-controller
drmSystemOptions: {}, // used by eme-controller
requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, // used by eme-controller
testBandwidth: true,

View file

@ -1,10 +1,8 @@
import EwmaBandWidthEstimator from '../utils/ewma-bandwidth-estimator';
import { Events } from '../events';
import { BufferHelper } from '../utils/buffer-helper';
import { ErrorDetails } from '../errors';
import { ErrorDetails, ErrorTypes } from '../errors';
import { PlaylistLevelType } from '../types/loader';
import { logger } from '../utils/logger';
import type { Bufferable } from '../utils/buffer-helper';
import type { Fragment } from '../loader/fragment';
import type { Part } from '../loader/fragment';
import type { LoaderStats } from '../types/loader';
@ -95,16 +93,19 @@ class AbrController implements ComponentAPI {
*/
private _abandonRulesCheck() {
const { fragCurrent: frag, partCurrent: part, hls } = this;
const { autoLevelEnabled, config, media } = hls;
const { autoLevelEnabled, media } = hls;
if (!frag || !media) {
return;
}
const stats: LoaderStats = part ? part.stats : frag.stats;
const duration = part ? part.duration : frag.duration;
// If loading has been aborted and not in lowLatencyMode, stop timer and return
if (stats.aborted) {
logger.warn('frag loader destroy or aborted, disarm abandonRules');
// If frag loading is aborted, complete, or from lowest level, stop timer and return
if (
stats.aborted ||
(stats.loaded && stats.loaded === stats.total) ||
frag.level === 0
) {
this.clearTimer();
// reset forced auto level value so that next level will be selected
this._nextAutoLevel = -1;
@ -121,6 +122,11 @@ class AbrController implements ComponentAPI {
return;
}
const bufferInfo = hls.mainForwardBufferInfo;
if (bufferInfo === null) {
return;
}
const requestDelay = performance.now() - stats.loading.start;
const playbackRate = Math.abs(media.playbackRate);
// In order to work with a stable bandwidth, only begin monitoring bandwidth after half of the fragment has been loaded
@ -128,32 +134,25 @@ class AbrController implements ComponentAPI {
return;
}
const loadedFirstByte = stats.loaded && stats.loading.first;
const bwEstimate: number = this.bwEstimator.getEstimate();
const { levels, minAutoLevel } = hls;
const level = levels[frag.level];
const expectedLen =
stats.total ||
Math.max(stats.loaded, Math.round((duration * level.maxBitrate) / 8));
const loadRate = Math.max(
1,
stats.bwEstimate
? stats.bwEstimate / 8
: (stats.loaded * 1000) / requestDelay
);
// fragLoadDelay is an estimate of the time (in seconds) it will take to buffer the entire fragment
const fragLoadedDelay = (expectedLen - stats.loaded) / loadRate;
const loadRate = loadedFirstByte ? (stats.loaded * 1000) / requestDelay : 0;
// fragLoadDelay is an estimate of the time (in seconds) it will take to buffer the remainder of the fragment
const fragLoadedDelay = loadRate
? (expectedLen - stats.loaded) / loadRate
: (expectedLen * 8) / bwEstimate;
const pos = media.currentTime;
// bufferStarvationDelay is an estimate of the amount time (in seconds) it will take to exhaust the buffer
const bufferStarvationDelay =
(BufferHelper.bufferInfo(media, pos, config.maxBufferHole).end - pos) /
playbackRate;
const bufferStarvationDelay = bufferInfo.len / playbackRate;
// Attempt an emergency downswitch only if less than 2 fragment lengths are buffered, and the time to finish loading
// the current fragment is greater than the amount of buffer we have left
if (
bufferStarvationDelay >= (2 * duration) / playbackRate ||
fragLoadedDelay <= bufferStarvationDelay
) {
// Only downswitch if the time to finish loading the current fragment is greater than the amount of buffer left
if (fragLoadedDelay <= bufferStarvationDelay) {
return;
}
@ -169,8 +168,9 @@ class AbrController implements ComponentAPI {
// 0.8 : consider only 80% of current bw to be conservative
// 8 = bits per byte (bps/Bps)
const levelNextBitrate = levels[nextLoadLevel].maxBitrate;
fragLevelNextLoadedDelay =
(duration * levelNextBitrate) / (8 * 0.8 * loadRate);
fragLevelNextLoadedDelay = loadRate
? (duration * levelNextBitrate) / (8 * 0.8 * loadRate)
: (duration * levelNextBitrate) / bwEstimate;
if (fragLevelNextLoadedDelay < bufferStarvationDelay) {
break;
@ -181,7 +181,6 @@ class AbrController implements ComponentAPI {
if (fragLevelNextLoadedDelay >= fragLoadedDelay) {
return;
}
const bwEstimate: number = this.bwEstimator.getEstimate();
logger.warn(`Fragment ${frag.sn}${
part ? ' part ' + part.index : ''
} of level ${
@ -196,11 +195,14 @@ class AbrController implements ComponentAPI {
)} s
Time to underbuffer: ${bufferStarvationDelay.toFixed(3)} s`);
hls.nextLoadLevel = nextLoadLevel;
this.bwEstimator.sample(requestDelay, stats.loaded);
if (loadedFirstByte) {
// If there has been loading progress, sample bandwidth
this.bwEstimator.sample(requestDelay, stats.loaded);
}
this.clearTimer();
if (frag.loader) {
if (frag.loader || frag.keyLoader) {
this.fragCurrent = this.partCurrent = null;
frag.loader.abort();
frag.abortRequests();
}
hls.trigger(Events.FRAG_LOAD_EMERGENCY_ABORTED, { frag, part, stats });
}
@ -273,13 +275,21 @@ class AbrController implements ComponentAPI {
protected onError(event: Events.ERROR, data: ErrorData) {
// stop timer in case of frag loading error
switch (data.details) {
case ErrorDetails.FRAG_LOAD_ERROR:
case ErrorDetails.FRAG_LOAD_TIMEOUT:
if (data.frag?.type === PlaylistLevelType.MAIN) {
if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) {
this.clearTimer();
break;
default:
break;
return;
}
switch (data.details) {
case ErrorDetails.FRAG_LOAD_ERROR:
case ErrorDetails.FRAG_LOAD_TIMEOUT:
case ErrorDetails.KEY_LOAD_ERROR:
case ErrorDetails.KEY_LOAD_TIMEOUT:
this.clearTimer();
break;
default:
break;
}
}
}
@ -319,7 +329,6 @@ class AbrController implements ComponentAPI {
: fragCurrent
? fragCurrent.duration
: 0;
const pos = media ? media.currentTime : 0;
// playbackRate is the absolute value of the playback rate; if media.playbackRate is 0, we use 1 to load as
// if we're playing back at the normal rate.
@ -329,11 +338,9 @@ class AbrController implements ComponentAPI {
? this.bwEstimator.getEstimate()
: config.abrEwmaDefaultEstimate;
// bufferStarvationDelay is the wall-clock time left until the playback buffer is exhausted.
const bufferInfo = hls.mainForwardBufferInfo;
const bufferStarvationDelay =
(BufferHelper.bufferInfo(media as Bufferable, pos, config.maxBufferHole)
.end -
pos) /
playbackRate;
(bufferInfo ? bufferInfo.len : 0) / playbackRate;
// First, look to see if we can find a level matching with our avg bandwidth AND that could also guarantee no rebuffering at all
let bestLevel = this.findBestLevel(
@ -461,7 +468,8 @@ class AbrController implements ComponentAPI {
// fragment fetchDuration unknown OR live stream OR fragment fetchDuration less than max allowed fetch duration, then this level matches
// we don't account for max Fetch Duration for live streams, this is to avoid switching down when near the edge of live sliding window ...
// special case to support startLevel = -1 (bitrateTest) on live streams : in that case we should not exit loop so that findBestLevel will return -1
(!fetchDuration ||
(fetchDuration === 0 ||
!Number.isFinite(fetchDuration) ||
(live && !this.bitrateTestDelay) ||
fetchDuration < maxFetchDuration)
) {

View file

@ -10,11 +10,12 @@ import TransmuxerInterface from '../demux/transmuxer-interface';
import { ChunkMetadata } from '../types/transmuxer';
import { fragmentWithinToleranceTest } from './fragment-finders';
import { alignMediaPlaylistByPDT } from '../utils/discontinuities';
import { ErrorDetails } from '../errors';
import { ErrorDetails, ErrorTypes } from '../errors';
import type { NetworkComponentAPI } from '../types/component-api';
import type { FragmentTracker } from './fragment-tracker';
import type { TransmuxerResult } from '../types/transmuxer';
import type Hls from '../hls';
import type { FragmentTracker } from './fragment-tracker';
import type KeyLoader from '../loader/key-loader';
import type { TransmuxerResult } from '../types/transmuxer';
import type { LevelDetails } from '../loader/level-details';
import type { TrackSet } from '../types/track';
import type {
@ -56,8 +57,12 @@ class AudioStreamController
private bufferFlushed: boolean = false;
private cachedTrackLoadedData: TrackLoadedData | null = null;
constructor(hls: Hls, fragmentTracker: FragmentTracker) {
super(hls, fragmentTracker, '[audio-stream-controller]');
constructor(
hls: Hls,
fragmentTracker: FragmentTracker,
keyLoader: KeyLoader
) {
super(hls, fragmentTracker, keyLoader, '[audio-stream-controller]');
this._registerListeners();
}
@ -252,12 +257,6 @@ class AudioStreamController
// Exit early if we don't have media or if the media hasn't buffered anything yet (readyState 0)
return;
}
const mediaBuffer = this.mediaBuffer ? this.mediaBuffer : media;
const buffered = mediaBuffer.buffered;
if (!this.loadedmetadata && buffered.length) {
this.loadedmetadata = true;
}
this.lastCurrentTime = media.currentTime;
}
@ -307,25 +306,25 @@ class AudioStreamController
if (bufferInfo === null) {
return;
}
const mainBufferInfo = this.getFwdBufferInfo(
this.videoBuffer ? this.videoBuffer : this.media,
PlaylistLevelType.MAIN
);
const bufferLen = bufferInfo.len;
const maxBufLen = this.getMaxBufferLength(mainBufferInfo?.len);
const audioSwitch = this.audioSwitch;
// if buffer length is less than maxBufLen try to load a new fragment
if (bufferLen >= maxBufLen && !audioSwitch) {
return;
}
if (!audioSwitch && this._streamEnded(bufferInfo, trackDetails)) {
hls.trigger(Events.BUFFER_EOS, { type: 'audio' });
this.state = State.ENDED;
return;
}
const mainBufferInfo = this.getFwdBufferInfo(
this.videoBuffer ? this.videoBuffer : this.media,
PlaylistLevelType.MAIN
);
const bufferLen = bufferInfo.len;
const maxBufLen = this.getMaxBufferLength(mainBufferInfo?.len);
// if buffer length is less than maxBufLen try to load a new fragment
if (bufferLen >= maxBufLen && !audioSwitch) {
return;
}
const fragments = trackDetails.fragments;
const start = fragments[0].start;
let targetBufferTime = bufferInfo.end;
@ -363,11 +362,7 @@ class AudioStreamController
return;
}
if (frag.decryptdata?.keyFormat === 'identity' && !frag.decryptdata?.key) {
this.loadKey(frag, trackDetails);
} else {
this.loadFragment(frag, trackDetails, targetBufferTime);
}
this.loadFragment(frag, trackDetails, targetBufferTime);
}
protected getMaxBufferLength(mainBufferLength?: number): number {
@ -400,8 +395,8 @@ class AudioStreamController
this.trackId = data.id;
const { fragCurrent } = this;
if (fragCurrent?.loader) {
fragCurrent.loader.abort();
if (fragCurrent) {
fragCurrent.abortRequests();
}
this.fragCurrent = null;
this.clearWaitingFragment();
@ -599,6 +594,11 @@ class AudioStreamController
onFragBuffered(event: Events.FRAG_BUFFERED, data: FragBufferedData) {
const { frag, part } = data;
if (frag.type !== PlaylistLevelType.AUDIO) {
if (!this.loadedmetadata && frag.type === PlaylistLevelType.MAIN) {
if ((this.videoBuffer || this.media)?.buffered.length) {
this.loadedmetadata = true;
}
}
return;
}
if (this.fragContextChanged(frag)) {
@ -624,9 +624,14 @@ class AudioStreamController
}
private onError(event: Events.ERROR, data: ErrorData) {
if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) {
this.onFragmentOrKeyLoadError(PlaylistLevelType.AUDIO, data);
return;
}
switch (data.details) {
case ErrorDetails.FRAG_LOAD_ERROR:
case ErrorDetails.FRAG_LOAD_TIMEOUT:
case ErrorDetails.FRAG_PARSING_ERROR:
case ErrorDetails.KEY_LOAD_ERROR:
case ErrorDetails.KEY_LOAD_TIMEOUT:
// TODO: Skip fragments that do not belong to this.fragCurrent audio-group id
@ -683,6 +688,9 @@ class AudioStreamController
) {
if (type === ElementaryStreamTypes.AUDIO) {
this.bufferFlushed = true;
if (this.state === State.ENDED) {
this.state = State.IDLE;
}
}
}
@ -827,7 +835,7 @@ class AudioStreamController
fragState === FragmentState.PARTIAL
) {
if (frag.sn === 'initSegment') {
this._loadInitSegment(frag);
this._loadInitSegment(frag, trackDetails);
} else if (trackDetails.live && !Number.isFinite(this.initPTS[frag.cc])) {
this.log(
`Waiting for video PTS in continuity counter ${frag.cc} of live stream before loading audio fragment ${frag.sn} of level ${this.trackId}`

View file

@ -240,6 +240,7 @@ class AudioTrackController extends BasePlaylistController {
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
super.loadPlaylist();
const audioTrack = this.tracksInGroup[this.trackId];
if (this.shouldLoadTrack(audioTrack)) {
const id = audioTrack.id;

View file

@ -17,6 +17,7 @@ import { ErrorTypes } from '../errors';
export default class BasePlaylistController implements NetworkComponentAPI {
protected hls: Hls;
protected timer: number = -1;
protected requestScheduled: number = -1;
protected canLoad: boolean = false;
protected retryCount: number = 0;
protected log: (msg: any) => void;
@ -35,8 +36,12 @@ export default class BasePlaylistController implements NetworkComponentAPI {
}
protected onError(event: Events.ERROR, data: ErrorData): void {
if (data.fatal && data.type === ErrorTypes.NETWORK_ERROR) {
this.clearTimer();
if (
data.fatal &&
(data.type === ErrorTypes.NETWORK_ERROR ||
data.type === ErrorTypes.KEY_SYSTEM_ERROR)
) {
this.stopLoad();
}
}
@ -48,6 +53,7 @@ export default class BasePlaylistController implements NetworkComponentAPI {
public startLoad(): void {
this.canLoad = true;
this.retryCount = 0;
this.requestScheduled = -1;
this.loadPlaylist();
}
@ -58,38 +64,48 @@ export default class BasePlaylistController implements NetworkComponentAPI {
protected switchParams(
playlistUri: string,
previous?: LevelDetails
previous: LevelDetails | undefined
): HlsUrlParameters | undefined {
const renditionReports = previous?.renditionReports;
if (renditionReports) {
for (let i = 0; i < renditionReports.length; i++) {
const attr = renditionReports[i];
const uri = '' + attr.URI;
let uri: string;
try {
uri = new self.URL(attr.URI, previous.url).href;
} catch (error) {
logger.warn(
`Could not construct new URL for Rendition Report: ${error}`
);
uri = attr.URI || '';
}
if (uri === playlistUri.slice(-uri.length)) {
const msn = parseInt(attr['LAST-MSN']);
let part = parseInt(attr['LAST-PART']);
if (previous && this.hls.config.lowLatencyMode) {
const msn = parseInt(attr['LAST-MSN']) || previous?.lastPartSn;
let part = parseInt(attr['LAST-PART']) || previous?.lastPartIndex;
if (this.hls.config.lowLatencyMode) {
const currentGoal = Math.min(
previous.age - previous.partTarget,
previous.targetduration
);
if (part !== undefined && currentGoal > previous.partTarget) {
if (part >= 0 && currentGoal > previous.partTarget) {
part += 1;
}
}
if (Number.isFinite(msn)) {
return new HlsUrlParameters(
msn,
Number.isFinite(part) ? part : undefined,
HlsSkip.No
);
}
return new HlsUrlParameters(
msn,
part >= 0 ? part : undefined,
HlsSkip.No
);
}
}
}
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
if (this.requestScheduled === -1) {
this.requestScheduled = self.performance.now();
}
}
protected shouldLoadTrack(track: MediaPlaylist): boolean {
return (
@ -108,8 +124,9 @@ export default class BasePlaylistController implements NetworkComponentAPI {
const { details, stats } = data;
// Set last updated date-time
const elapsed = stats.loading.end
? Math.max(0, self.performance.now() - stats.loading.end)
const now = self.performance.now();
const elapsed = stats.loading.first
? Math.max(0, now - stats.loading.first)
: 0;
details.advancedDateTime = Date.now() - elapsed;
@ -204,16 +221,53 @@ export default class BasePlaylistController implements NetworkComponentAPI {
part
);
}
let reloadInterval = computeReloadInterval(details, stats);
if (msn !== undefined && details.canBlockReload) {
reloadInterval -= details.partTarget || 1;
}
this.log(
`reload live playlist ${index} in ${Math.round(reloadInterval)} ms`
const bufferInfo = this.hls.mainForwardBufferInfo;
const position = bufferInfo ? bufferInfo.end - bufferInfo.len : 0;
const distanceToLiveEdgeMs = (details.edge - position) * 1000;
const reloadInterval = computeReloadInterval(
details,
distanceToLiveEdgeMs
);
if (!details.updated) {
this.requestScheduled = -1;
} else if (now > this.requestScheduled + reloadInterval) {
this.requestScheduled = stats.loading.start;
}
if (msn !== undefined && details.canBlockReload) {
this.requestScheduled =
stats.loading.first +
reloadInterval -
(details.partTarget * 1000 || 1000);
} else {
this.requestScheduled =
(this.requestScheduled === -1 ? now : this.requestScheduled) +
reloadInterval;
}
let estimatedTimeUntilUpdate = this.requestScheduled - now;
estimatedTimeUntilUpdate = Math.max(0, estimatedTimeUntilUpdate);
this.log(
`reload live playlist ${index} in ${Math.round(
estimatedTimeUntilUpdate
)} ms`
);
// this.log(
// `live reload ${details.updated ? 'REFRESHED' : 'MISSED'}
// reload in ${estimatedTimeUntilUpdate / 1000}
// round trip ${(stats.loading.end - stats.loading.start) / 1000}
// diff ${
// (reloadInterval -
// (estimatedTimeUntilUpdate + stats.loading.end - stats.loading.start)) /
// 1000
// }
// reload interval ${reloadInterval / 1000}
// target duration ${details.targetduration}
// distance to edge ${distanceToLiveEdgeMs / 1000}`
// );
this.timer = self.setTimeout(
() => this.loadPlaylist(deliveryDirectives),
reloadInterval
estimatedTimeUntilUpdate
);
} else {
this.clearTimer();
@ -239,6 +293,7 @@ export default class BasePlaylistController implements NetworkComponentAPI {
const { config } = this.hls;
const retry = this.retryCount < config.levelLoadingMaxRetry;
if (retry) {
this.requestScheduled = -1;
this.retryCount++;
if (
errorEvent.details.indexOf('LoadTimeOut') > -1 &&

View file

@ -1,9 +1,9 @@
import TaskLoop from '../task-loop';
import { FragmentState } from './fragment-tracker';
import { Bufferable, BufferHelper } from '../utils/buffer-helper';
import { Bufferable, BufferHelper, BufferInfo } from '../utils/buffer-helper';
import { logger } from '../utils/logger';
import { Events } from '../events';
import { ErrorDetails } from '../errors';
import { ErrorDetails, ErrorTypes } from '../errors';
import { ChunkMetadata } from '../types/transmuxer';
import { appendUint8Array } from '../utils/mp4-tools';
import { alignStream } from '../utils/discontinuities';
@ -23,6 +23,7 @@ import FragmentLoader, {
FragmentLoadProgressCallback,
LoadError,
} from '../loader/fragment-loader';
import KeyLoader from '../loader/key-loader';
import { LevelDetails } from '../loader/level-details';
import Decrypter from '../crypt/decrypter';
import TimeRanges from '../utils/time-ranges';
@ -42,7 +43,6 @@ import type { Level } from '../types/level';
import type { RemuxedTrack } from '../types/remuxer';
import type Hls from '../hls';
import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
import type { NetworkComponentAPI } from '../types/component-api';
import type { SourceBufferName } from '../types/buffer';
@ -86,7 +86,8 @@ export default class BaseStreamController
protected fragLoadError: number = 0;
protected retryDate: number = 0;
protected levels: Array<Level> | null = null;
protected fragmentLoader!: FragmentLoader;
protected fragmentLoader: FragmentLoader;
protected keyLoader: KeyLoader;
protected levelLastLoaded: number | null = null;
protected startFragRequested: boolean = false;
protected decrypter: Decrypter;
@ -98,17 +99,22 @@ export default class BaseStreamController
protected log: (msg: any) => void;
protected warn: (msg: any) => void;
constructor(hls: Hls, fragmentTracker: FragmentTracker, logPrefix: string) {
constructor(
hls: Hls,
fragmentTracker: FragmentTracker,
keyLoader: KeyLoader,
logPrefix: string
) {
super();
this.logPrefix = logPrefix;
this.log = logger.log.bind(logger, `${logPrefix}:`);
this.warn = logger.warn.bind(logger, `${logPrefix}:`);
this.hls = hls;
this.fragmentLoader = new FragmentLoader(hls.config);
this.keyLoader = keyLoader;
this.fragmentTracker = fragmentTracker;
this.config = hls.config;
this.decrypter = new Decrypter(hls as HlsEventEmitter, hls.config);
hls.on(Events.KEY_LOADED, this.onKeyLoaded, this);
this.decrypter = new Decrypter(hls.config);
hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
}
@ -123,8 +129,10 @@ export default class BaseStreamController
public stopLoad() {
this.fragmentLoader.abort();
this.keyLoader.abort();
const frag = this.fragCurrent;
if (frag) {
frag.abortRequests();
this.fragmentTracker.removeFragment(frag);
}
this.resetTransmuxer();
@ -135,43 +143,46 @@ export default class BaseStreamController
this.state = State.STOPPED;
}
protected _streamEnded(bufferInfo, levelDetails: LevelDetails): boolean {
const { fragCurrent, fragmentTracker } = this;
// we just got done loading the final fragment and there is no other buffered range after ...
// rationale is that in case there are any buffered ranges after, it means that there are unbuffered portion in between
// so we should not switch to ENDED in that case, to be able to buffer them
protected _streamEnded(
bufferInfo: BufferInfo,
levelDetails: LevelDetails
): boolean {
// If playlist is live, there is another buffered range after the current range, nothing buffered, media is detached,
// of nothing loading/loaded return false
if (
!levelDetails.live &&
fragCurrent &&
this.media &&
// NOTE: Because of the way parts are currently parsed/represented in the playlist, we can end up
// in situations where the current fragment is actually greater than levelDetails.endSN. While
// this feels like the "wrong place" to account for that, this is a narrower/safer change than
// updating e.g. M3U8Parser::parseLevelPlaylist().
fragCurrent.sn >= levelDetails.endSN &&
!bufferInfo.nextStart
levelDetails.live ||
bufferInfo.nextStart ||
!bufferInfo.end ||
!this.media
) {
const partList = levelDetails.partList;
// Since the last part isn't guaranteed to correspond to fragCurrent for ll-hls, check instead if the last part is buffered.
if (partList?.length) {
const lastPart = partList[partList.length - 1];
// Checking the midpoint of the part for potential margin of error and related issues.
// NOTE: Technically I believe parts could yield content that is < the computed duration (including potential a duration of 0)
// and still be spec-compliant, so there may still be edge cases here. Likewise, there could be issues in end of stream
// part mismatches for independent audio and video playlists/segments.
const lastPartBuffered = BufferHelper.isBuffered(
this.media,
lastPart.start + lastPart.duration / 2
);
return lastPartBuffered;
}
const fragState = fragmentTracker.getState(fragCurrent);
return (
fragState === FragmentState.PARTIAL || fragState === FragmentState.OK
);
return false;
}
const partList = levelDetails.partList;
// Since the last part isn't guaranteed to correspond to the last playlist segment for Low-Latency HLS,
// check instead if the last part is buffered.
if (partList?.length) {
const lastPart = partList[partList.length - 1];
// Checking the midpoint of the part for potential margin of error and related issues.
// NOTE: Technically I believe parts could yield content that is < the computed duration (including potential a duration of 0)
// and still be spec-compliant, so there may still be edge cases here. Likewise, there could be issues in end of stream
// part mismatches for independent audio and video playlists/segments.
const lastPartBuffered = BufferHelper.isBuffered(
this.media,
lastPart.start + lastPart.duration / 2
);
return lastPartBuffered;
}
const playlistType =
levelDetails.fragments[levelDetails.fragments.length - 1].type;
return this.fragmentTracker.isEndListAppended(playlistType);
}
protected getLevelDetails(): LevelDetails | undefined {
if (this.levels && this.levelLastLoaded !== null) {
return this.levels[this.levelLastLoaded]?.details;
}
return false;
}
protected onMediaAttached(
@ -202,6 +213,9 @@ export default class BaseStreamController
media.removeEventListener('ended', this.onvended);
this.onvseeking = this.onvended = null;
}
if (this.keyLoader) {
this.keyLoader.detach();
}
this.media = this.mediaBuffer = null;
this.loadedmetadata = false;
this.fragmentTracker.removeAllFragments();
@ -223,24 +237,31 @@ export default class BaseStreamController
}, state: ${state}`
);
if (state === State.ENDED) {
if (this.state === State.ENDED) {
this.resetLoadingState();
} else if (fragCurrent && !bufferInfo.len) {
// check if we are seeking to a unbuffered area AND if frag loading is in progress
} else if (fragCurrent) {
// Seeking while frag load is in progress
const tolerance = config.maxFragLookUpTolerance;
const fragStartOffset = fragCurrent.start - tolerance;
const fragEndOffset =
fragCurrent.start + fragCurrent.duration + tolerance;
const pastFragment = currentTime > fragEndOffset;
// check if the seek position is past current fragment, and if so abort loading
if (currentTime < fragStartOffset || pastFragment) {
if (pastFragment && fragCurrent.loader) {
this.log(
'seeking outside of buffer while fragment load in progress, cancel fragment load'
);
fragCurrent.loader.abort();
// if seeking out of buffered range or into new one
if (
!bufferInfo.len ||
fragEndOffset < bufferInfo.start ||
fragStartOffset > bufferInfo.end
) {
const pastFragment = currentTime > fragEndOffset;
// if the seek position is outside the current fragment range
if (currentTime < fragStartOffset || pastFragment) {
if (pastFragment && fragCurrent.loader) {
this.log(
'seeking outside of buffer while fragment load in progress, cancel fragment load'
);
fragCurrent.abortRequests();
}
this.resetLoadingState();
}
this.resetLoadingState();
}
}
@ -262,21 +283,6 @@ export default class BaseStreamController
this.startPosition = this.lastCurrentTime = 0;
}
onKeyLoaded(event: Events.KEY_LOADED, data: KeyLoadedData) {
if (
this.state !== State.KEY_LOADING ||
data.frag !== this.fragCurrent ||
!this.levels
) {
return;
}
this.state = State.IDLE;
const levelDetails = this.levels[data.frag.level].details;
if (levelDetails) {
this.loadFragment(data.frag, levelDetails, data.frag.start);
}
}
protected onLevelSwitching(
event: Events.LEVEL_SWITCHING,
data: LevelSwitchingData
@ -291,11 +297,13 @@ export default class BaseStreamController
protected onHandlerDestroyed() {
this.state = State.STOPPED;
this.hls.off(Events.KEY_LOADED, this.onKeyLoaded, this);
this.hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
if (this.fragmentLoader) {
this.fragmentLoader.destroy();
}
if (this.keyLoader) {
this.keyLoader.destroy();
}
if (this.decrypter) {
this.decrypter.destroy();
}
@ -304,23 +312,13 @@ export default class BaseStreamController
this.log =
this.warn =
this.decrypter =
this.keyLoader =
this.fragmentLoader =
this.fragmentTracker =
null as any;
super.onHandlerDestroyed();
}
protected loadKey(frag: Fragment, details: LevelDetails) {
this.log(
`Loading key for ${frag.sn} of [${details.startSN}-${details.endSN}], ${
this.logPrefix === '[stream-controller]' ? 'level' : 'track'
} ${frag.level}`
);
this.state = State.KEY_LOADING;
this.fragCurrent = frag;
this.hls.trigger(Events.KEY_LOADING, { frag });
}
protected loadFragment(
frag: Fragment,
levelDetails: LevelDetails,
@ -402,8 +400,8 @@ export default class BaseStreamController
this.hls.trigger(Events.BUFFER_FLUSHING, flushScope);
}
protected _loadInitSegment(frag: Fragment) {
this._doFragLoad(frag)
protected _loadInitSegment(frag: Fragment, details: LevelDetails) {
this._doFragLoad(frag, details)
.then((data) => {
if (!data || this.fragContextChanged(frag) || !this.levels) {
throw new Error('init load aborted');
@ -428,7 +426,7 @@ export default class BaseStreamController
const startTime = self.performance.now();
// decrypt the subtitles
return this.decrypter
.webCryptoDecrypt(
.decrypt(
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer
@ -508,11 +506,13 @@ export default class BaseStreamController
part ? ' part: ' + part.index : ''
} of ${this.logPrefix === '[stream-controller]' ? 'level' : 'track'} ${
frag.level
} ${
} (frag:[${(frag.startPTS || NaN).toFixed(3)}-${(
frag.endPTS || NaN
).toFixed(3)}] > buffer:${
media
? TimeRanges.toString(BufferHelper.getBuffered(media))
: '(detached)'
}`
})`
);
this.state = State.IDLE;
if (!media) {
@ -520,8 +520,9 @@ export default class BaseStreamController
}
if (
!this.loadedmetadata &&
frag.type == PlaylistLevelType.MAIN &&
media.buffered.length &&
this.fragCurrent === this.fragPrevious
this.fragCurrent?.sn === this.fragPrevious?.sn
) {
this.loadedmetadata = true;
this.seekToStartPos();
@ -554,17 +555,44 @@ export default class BaseStreamController
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _handleFragmentLoadProgress(frag: FragLoadedData) {}
protected _handleFragmentLoadProgress(
frag: PartsLoadedData | FragLoadedData
) {}
protected _doFragLoad(
frag: Fragment,
details?: LevelDetails,
details: LevelDetails,
targetBufferTime: number | null = null,
progressCallback?: FragmentLoadProgressCallback
): Promise<PartsLoadedData | FragLoadedData | null> {
if (!this.levels) {
throw new Error('frag load aborted, missing levels');
}
let keyLoadingPromise: Promise<KeyLoadedData | void> | null = null;
if (frag.encrypted && !frag.decryptdata?.key) {
this.log(
`Loading key for ${frag.sn} of [${details.startSN}-${details.endSN}], ${
this.logPrefix === '[stream-controller]' ? 'level' : 'track'
} ${frag.level}`
);
this.state = State.KEY_LOADING;
this.fragCurrent = frag;
keyLoadingPromise = this.keyLoader.load(frag).then((keyLoadedData) => {
if (!this.fragContextChanged(keyLoadedData.frag)) {
this.hls.trigger(Events.KEY_LOADED, keyLoadedData);
if (this.state === State.KEY_LOADING) {
this.state = State.IDLE;
}
return keyLoadedData;
}
});
this.hls.trigger(Events.KEY_LOADING, { frag });
this.throwIfFragContextChanged('KEY_LOADING');
} else if (!frag.encrypted && details.encryptedFragments.length) {
this.keyLoader.loadClear(frag, details.encryptedFragments);
}
targetBufferTime = Math.max(frag.start, targetBufferTime || 0);
if (this.config.lowLatencyMode && details) {
const partList = details.partList;
@ -593,6 +621,26 @@ export default class BaseStreamController
part: partList[partIndex],
targetBufferTime,
});
this.throwIfFragContextChanged('FRAG_LOADING parts');
if (keyLoadingPromise) {
return keyLoadingPromise
.then((keyLoadedData) => {
if (
!keyLoadedData ||
this.fragContextChanged(keyLoadedData.frag)
) {
return null;
}
return this.doFragPartsLoad(
frag,
partList,
partIndex,
progressCallback
);
})
.catch((error) => this.handleFragLoadError(error));
}
return this.doFragPartsLoad(
frag,
partList,
@ -622,10 +670,44 @@ export default class BaseStreamController
}
this.state = State.FRAG_LOADING;
this.hls.trigger(Events.FRAG_LOADING, { frag, targetBufferTime });
this.throwIfFragContextChanged('FRAG_LOADING');
return this.fragmentLoader
.load(frag, progressCallback)
.catch((error: LoadError) => this.handleFragLoadError(error));
// Load key before streaming fragment data
const dataOnProgress = this.config.progressive;
if (dataOnProgress && keyLoadingPromise) {
return keyLoadingPromise
.then((keyLoadedData) => {
if (!keyLoadedData || this.fragContextChanged(keyLoadedData?.frag)) {
return null;
}
return this.fragmentLoader.load(frag, progressCallback);
})
.catch((error) => this.handleFragLoadError(error));
}
// load unencrypted fragment data with progress event,
// or handle fragment result after key and fragment are finished loading
return Promise.all([
this.fragmentLoader.load(
frag,
dataOnProgress ? progressCallback : undefined
),
keyLoadingPromise,
])
.then(([fragLoadedData]) => {
if (!dataOnProgress && fragLoadedData && progressCallback) {
progressCallback(fragLoadedData);
}
return fragLoadedData;
})
.catch((error) => this.handleFragLoadError(error));
}
private throwIfFragContextChanged(context: string): void | never {
// exit if context changed during event loop
if (this.fragCurrent === null) {
throw new Error(`frag load aborted, context changed in ${context}`);
}
}
private doFragPartsLoad(
@ -663,11 +745,21 @@ export default class BaseStreamController
);
}
private handleFragLoadError({ data }: LoadError) {
if (data && data.details === ErrorDetails.INTERNAL_ABORTED) {
this.handleFragLoadAborted(data.frag, data.part);
private handleFragLoadError(error: LoadError | Error) {
if ('data' in error) {
const data = error.data;
if (error.data && data.details === ErrorDetails.INTERNAL_ABORTED) {
this.handleFragLoadAborted(data.frag, data.part);
} else {
this.hls.trigger(Events.ERROR, data as ErrorData);
}
} else {
this.hls.trigger(Events.ERROR, data as ErrorData);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.OTHER_ERROR,
details: ErrorDetails.INTERNAL_EXCEPTION,
err: error,
fatal: true,
});
}
return null;
}
@ -675,7 +767,11 @@ export default class BaseStreamController
protected _handleTransmuxerFlush(chunkMeta: ChunkMetadata) {
const context = this.getCurrentContext(chunkMeta);
if (!context || this.state !== State.PARSING) {
if (!this.fragCurrent) {
if (
!this.fragCurrent &&
this.state !== State.STOPPED &&
this.state !== State.ERROR
) {
this.state = State.IDLE;
}
return;
@ -778,12 +874,7 @@ export default class BaseStreamController
protected getFwdBufferInfo(
bufferable: Bufferable | null,
type: PlaylistLevelType
): {
len: number;
start: number;
end: number;
nextStart?: number;
} | null {
): BufferInfo | null {
const { config } = this;
const pos = this.getLoadPosition();
if (!Number.isFinite(pos)) {
@ -912,8 +1003,9 @@ export default class BaseStreamController
break;
}
const loaded = part.loaded;
if (
!loaded &&
if (loaded) {
nextPart = -1;
} else if (
(contiguous || part.independent || independentAttrOmitted) &&
part.fragment === frag
) {
@ -1007,7 +1099,8 @@ export default class BaseStreamController
end: number,
levelDetails: LevelDetails
): Fragment | null {
const { config, fragPrevious } = this;
const { config } = this;
let { fragPrevious } = this;
let { fragments, endSN } = levelDetails;
const { fragmentHint } = levelDetails;
const tolerance = config.maxFragLookUpTolerance;
@ -1041,6 +1134,11 @@ export default class BaseStreamController
if (frag) {
const curSNIdx = frag.sn - levelDetails.startSN;
// Move fragPrevious forward to support forcing the next fragment to load
// when the buffer catches up to a previously buffered range.
if (this.fragmentTracker.getState(frag) === FragmentState.OK) {
fragPrevious = frag;
}
if (fragPrevious && frag.sn === fragPrevious.sn && !loadingParts) {
// Force the next fragment to load if the previous one was already selected. This can occasionally happen with
// non-uniform fragment durations
@ -1227,8 +1325,20 @@ export default class BaseStreamController
data: ErrorData
) {
if (data.fatal) {
this.stopLoad();
this.state = State.ERROR;
return;
}
const config = this.config;
if (data.chunkMeta) {
// Parsing Error: no retries
const context = this.getCurrentContext(data.chunkMeta);
if (context) {
data.frag = context.frag;
data.levelRetry = true;
this.fragLoadError = config.fragLoadingMaxRetry;
}
}
const frag = data.frag;
// Handle frag error related to caller's filterType
if (!frag || frag.type !== filterType) {
@ -1242,7 +1352,6 @@ export default class BaseStreamController
frag.urlId === fragCurrent.urlId,
'Frag load error must match current frag to retry'
);
const config = this.config;
// keep retrying until the limit will be reached
if (this.fragLoadError + 1 <= config.fragLoadingMaxRetry) {
if (!this.loadedmetadata) {
@ -1302,6 +1411,7 @@ export default class BaseStreamController
}
protected resetLoadingState() {
this.log('Reset loading state');
this.fragCurrent = null;
this.fragPrevious = null;
this.state = State.IDLE;

View file

@ -24,8 +24,9 @@ import type {
FragChangedData,
} from '../types/events';
import type { ComponentAPI } from '../types/component-api';
import type { ChunkMetadata } from '../types/transmuxer';
import type Hls from '../hls';
import { LevelDetails } from '../loader/level-details';
import type { LevelDetails } from '../loader/level-details';
const MediaSource = getMediaSource();
const VIDEO_CODEC_PROFILE_REPACE = /([ha]vc.)(?:\.[^.,]+)+/;
@ -54,6 +55,9 @@ export default class BufferController implements ComponentAPI {
// A reference to the active media source
public mediaSource: MediaSource | null = null;
// Last MP3 audio chunk appended
private lastMpegAudioChunk: ChunkMetadata | null = null;
// counters
public appendError: number = 0;
@ -77,6 +81,7 @@ export default class BufferController implements ComponentAPI {
public destroy() {
this.unregisterListeners();
this.details = null;
this.lastMpegAudioChunk = null;
}
protected registerListeners() {
@ -117,6 +122,7 @@ export default class BufferController implements ComponentAPI {
video: [],
audiovideo: [],
};
this.lastMpegAudioChunk = null;
}
protected onManifestParsed(
@ -153,6 +159,7 @@ export default class BufferController implements ComponentAPI {
media.src = self.URL.createObjectURL(ms);
// cache the locally generated object url
this._objectUrl = media.src;
media.addEventListener('emptied', this._onMediaEmptied);
}
}
@ -182,6 +189,7 @@ export default class BufferController implements ComponentAPI {
// Detach properly the MediaSource from the HTMLMediaElement as
// suggested in https://github.com/w3c/media-source/issues/53.
if (media) {
media.removeEventListener('emptied', this._onMediaEmptied);
if (_objectUrl) {
self.URL.revokeObjectURL(_objectUrl);
}
@ -340,23 +348,28 @@ export default class BufferController implements ComponentAPI {
// is greater than 100ms (this is enough to handle seek for VOD or level change for LIVE videos).
// More info here: https://github.com/video-dev/hls.js/issues/332#issuecomment-257986486
const audioTrack = tracks.audio;
const checkTimestampOffset =
type === 'audio' &&
chunkMeta.id === 1 &&
audioTrack?.container === 'audio/mpeg';
let checkTimestampOffset = false;
if (type === 'audio' && audioTrack?.container === 'audio/mpeg') {
checkTimestampOffset =
!this.lastMpegAudioChunk ||
chunkMeta.id === 1 ||
this.lastMpegAudioChunk.sn !== chunkMeta.sn;
this.lastMpegAudioChunk = chunkMeta;
}
const fragStart = frag.start;
const operation: BufferOperation = {
execute: () => {
chunkStats.executeStart = self.performance.now();
if (checkTimestampOffset) {
const sb = this.sourceBuffer[type];
if (sb) {
const delta = frag.start - sb.timestampOffset;
const delta = fragStart - sb.timestampOffset;
if (Math.abs(delta) >= 0.1) {
logger.log(
`[buffer-controller]: Updating audio SourceBuffer timestampOffset to ${frag.start} (delta: ${delta}) sn: ${frag.sn})`
`[buffer-controller]: Updating audio SourceBuffer timestampOffset to ${fragStart} (delta: ${delta}) sn: ${frag.sn})`
);
sb.timestampOffset = frag.start;
sb.timestampOffset = fragStart;
}
}
}
@ -515,8 +528,9 @@ export default class BufferController implements ComponentAPI {
protected onBufferEos(event: Events.BUFFER_EOS, data: BufferEOSData) {
const ended = this.getSourceBufferTypes().reduce((acc, type) => {
const sb = this.sourceBuffer[type];
if (!data.type || data.type === type) {
if (sb && !sb.ended) {
if (sb && (!data.type || data.type === type)) {
sb.ending = true;
if (!sb.ended) {
sb.ended = true;
logger.log(`[buffer-controller]: ${type} sourceBuffer now EOS`);
}
@ -525,11 +539,24 @@ export default class BufferController implements ComponentAPI {
}, true);
if (ended) {
logger.log(`[buffer-controller]: Queueing mediaSource.endOfStream()`);
this.blockBuffers(() => {
this.getSourceBufferTypes().forEach((type) => {
const sb = this.sourceBuffer[type];
if (sb) {
sb.ending = false;
}
});
const { mediaSource } = this;
if (!mediaSource || mediaSource.readyState !== 'open') {
if (mediaSource) {
logger.info(
`[buffer-controller]: Could not call mediaSource.endOfStream(). mediaSource.readyState: ${mediaSource.readyState}`
);
}
return;
}
logger.log(`[buffer-controller]: Calling mediaSource.endOfStream()`);
// Allow this to throw and be caught by the enqueueing function
mediaSource.endOfStream();
});
@ -597,6 +624,14 @@ export default class BufferController implements ComponentAPI {
hls.trigger(Events.LIVE_BACK_BUFFER_REACHED, {
bufferEnd: targetBackBufferPosition,
});
} else if (
sb.ended &&
buffered.end(buffered.length - 1) - currentTime < targetDuration * 2
) {
logger.info(
`[buffer-controller]: Cannot flush ${type} back buffer while SourceBuffer is in ended state`
);
return;
}
hls.trigger(Events.BUFFER_FLUSHING, {
@ -752,11 +787,12 @@ export default class BufferController implements ComponentAPI {
// Keep as arrow functions so that we can directly reference these functions directly as event listeners
private _onMediaSourceOpen = () => {
const { hls, media, mediaSource } = this;
const { media, mediaSource } = this;
logger.log('[buffer-controller]: Media source opened');
if (media) {
media.removeEventListener('emptied', this._onMediaEmptied);
this.updateMediaElementDuration();
hls.trigger(Events.MEDIA_ATTACHED, { media });
this.hls.trigger(Events.MEDIA_ATTACHED, { media });
}
if (mediaSource) {
@ -774,6 +810,15 @@ export default class BufferController implements ComponentAPI {
logger.log('[buffer-controller]: Media source ended');
};
private _onMediaEmptied = () => {
const { media, _objectUrl } = this;
if (media && media.src !== _objectUrl) {
logger.error(
`Media element src was set while attaching MediaSource (${_objectUrl} > ${media.src})`
);
}
};
private _onSBUpdateStart(type: SourceBufferName) {
const { operationQueue } = this;
const operation = operationQueue.current(type);
@ -826,7 +871,8 @@ export default class BufferController implements ComponentAPI {
: Infinity;
const removeStart = Math.max(0, startOffset);
const removeEnd = Math.min(endOffset, mediaDuration, msDuration);
if (removeEnd > removeStart) {
if (removeEnd > removeStart && !sb.ending) {
sb.ended = false;
logger.log(
`[buffer-controller]: Removing [${removeStart},${removeEnd}] from the ${type} SourceBuffer`
);

View file

@ -90,6 +90,7 @@ class CapLevelController implements ComponentAPI {
data: MediaAttachingData
) {
this.media = data.media instanceof HTMLVideoElement ? data.media : null;
this.clientRect = null;
}
protected onManifestParsed(

File diff suppressed because it is too large Load diff

View file

@ -82,7 +82,7 @@ export function findFragmentByPTS(
fragments,
fragmentWithinToleranceTest.bind(null, bufferEnd, maxFragLookUpTolerance)
);
if (foundFragment) {
if (foundFragment && (foundFragment !== fragPrevious || !fragNext)) {
return foundFragment;
}
// If no match was found return the next fragment after fragPrevious, or null
@ -101,6 +101,13 @@ export function fragmentWithinToleranceTest(
maxFragLookUpTolerance = 0,
candidate: Fragment
) {
// eagerly accept an accurate match (no tolerance)
if (
candidate.start <= bufferEnd &&
candidate.start + candidate.duration > bufferEnd
) {
return 0;
}
// offset should be within fragment boundary - config.maxFragLookUpTolerance
// this is to cope with situations like
// bufferEnd = 9.991

View file

@ -25,11 +25,13 @@ export enum FragmentState {
export class FragmentTracker implements ComponentAPI {
private activeFragment: Fragment | null = null;
private activeParts: Part[] | null = null;
private endListFragments: { [key in PlaylistLevelType]?: FragmentEntity } =
Object.create(null);
private fragments: Partial<Record<string, FragmentEntity>> =
Object.create(null);
private timeRanges:
| {
[key in SourceBufferName]: TimeRanges;
[key in SourceBufferName]?: TimeRanges;
}
| null = Object.create(null);
@ -59,7 +61,13 @@ export class FragmentTracker implements ComponentAPI {
public destroy() {
this._unregisterListeners();
// @ts-ignore
this.fragments = this.timeRanges = null;
this.fragments =
// @ts-ignore
this.endListFragments =
this.timeRanges =
this.activeFragment =
this.activeParts =
null;
}
/**
@ -137,13 +145,16 @@ export class FragmentTracker implements ComponentAPI {
timeRange: TimeRanges,
playlistType?: PlaylistLevelType
) {
if (this.timeRanges) {
this.timeRanges[elementaryStream] = timeRange;
}
// Check if any flagged fragments have been unloaded
Object.keys(this.fragments).forEach((key) => {
const fragmentEntity = this.fragments[key];
if (!fragmentEntity) {
return;
}
if (!fragmentEntity.buffered) {
if (!fragmentEntity.buffered && !fragmentEntity.loaded) {
if (fragmentEntity.body.type === playlistType) {
this.removeFragment(fragmentEntity.body);
}
@ -201,6 +212,9 @@ export class FragmentTracker implements ComponentAPI {
fragmentEntity.loaded = null;
if (Object.keys(fragmentEntity.range).length) {
fragmentEntity.buffered = true;
if (fragmentEntity.body.endList) {
this.endListFragments[fragmentEntity.body.type] = fragmentEntity;
}
} else {
// remove fragment if nothing was appended
this.removeFragment(fragmentEntity.body);
@ -288,6 +302,14 @@ export class FragmentTracker implements ComponentAPI {
return bestFragment;
}
public isEndListAppended(type: PlaylistLevelType): boolean {
const lastFragmentEntity = this.endListFragments[type];
return (
lastFragmentEntity !== undefined &&
(lastFragmentEntity.buffered || isPartial(lastFragmentEntity))
);
}
public getState(fragment: Fragment): FragmentState {
const fragKey = getFragmentKey(fragment);
const fragmentEntity = this.fragments[fragKey];
@ -352,7 +374,10 @@ export class FragmentTracker implements ComponentAPI {
) {
const { frag, part, timeRanges } = data;
if (frag.type === PlaylistLevelType.MAIN) {
this.activeFragment = frag;
if (this.activeFragment !== frag) {
this.activeFragment = frag;
frag.appendedPTS = undefined;
}
if (part) {
let activeParts = this.activeParts;
if (!activeParts) {
@ -364,13 +389,22 @@ export class FragmentTracker implements ComponentAPI {
}
}
// Store the latest timeRanges loaded in the buffer
this.timeRanges = timeRanges as { [key in SourceBufferName]: TimeRanges };
this.timeRanges = timeRanges;
Object.keys(timeRanges).forEach((elementaryStream: SourceBufferName) => {
const timeRange = timeRanges[elementaryStream] as TimeRanges;
this.detectEvictedFragments(elementaryStream, timeRange);
if (!part) {
if (!part && frag.type === PlaylistLevelType.MAIN) {
const streamInfo = frag.elementaryStreams[elementaryStream];
if (!streamInfo) {
return;
}
for (let i = 0; i < timeRange.length; i++) {
frag.appendedPTS = Math.max(timeRange.end(i), frag.appendedPTS || 0);
const rangeEnd = timeRange.end(i);
if (rangeEnd <= streamInfo.endPTS && rangeEnd > streamInfo.startPTS) {
frag.appendedPTS = Math.max(rangeEnd, frag.appendedPTS || 0);
} else {
frag.appendedPTS = streamInfo.endPTS;
}
}
}
});
@ -412,11 +446,16 @@ export class FragmentTracker implements ComponentAPI {
const fragKey = getFragmentKey(fragment);
fragment.stats.loaded = 0;
fragment.clearElementaryStreamInfo();
fragment.appendedPTS = undefined;
delete this.fragments[fragKey];
if (fragment.endList) {
delete this.endListFragments[fragment.type];
}
}
public removeAllFragments() {
this.fragments = Object.create(null);
this.endListFragments = Object.create(null);
this.activeFragment = null;
this.activeParts = null;
}

View file

@ -33,6 +33,18 @@ function getCueClass() {
return (self.WebKitDataCue || self.VTTCue || self.TextTrackCue) as any;
}
// VTTCue latest draft allows an infinite duration, fallback
// to MAX_VALUE if necessary
const MAX_CUE_ENDTIME = (() => {
const Cue = getCueClass();
try {
new Cue(0, Number.POSITIVE_INFINITY, '');
} catch (e) {
return Number.MAX_VALUE;
}
return Number.POSITIVE_INFINITY;
})();
function dateRangeDateToTimelineSeconds(date: Date, offset: number): number {
return date.getTime() / 1000 - offset;
}
@ -151,18 +163,14 @@ class ID3TrackController implements ComponentAPI {
return;
}
const { frag: fragment, samples, details } = data;
const { samples } = data;
// create track dynamically
if (!this.id3Track) {
this.id3Track = this.createTrack(this.media);
}
// VTTCue end time must be finite, so use playlist edge or fragment end until next fragment with same frame type is found
const maxCueTime = details.edge || fragment.end;
const Cue = getCueClass();
let updateCueRanges = false;
const frameTypesAdded: Record<string, number | null> = {};
for (let i = 0; i < samples.length; i++) {
const type = samples[i].type;
@ -176,7 +184,11 @@ class ID3TrackController implements ComponentAPI {
const frames = ID3.getID3Frames(samples[i].data);
if (frames) {
const startTime = samples[i].pts;
let endTime: number = maxCueTime;
let endTime: number = startTime + samples[i].duration;
if (endTime > MAX_CUE_ENDTIME) {
endTime = MAX_CUE_ENDTIME;
}
const timeDiff = endTime - startTime;
if (timeDiff <= 0) {
@ -187,36 +199,28 @@ class ID3TrackController implements ComponentAPI {
const frame = frames[j];
// Safari doesn't put the timestamp frame in the TextTrack
if (!ID3.isTimeStampFrame(frame)) {
// add a bounds to any unbounded cues
this.updateId3CueEnds(startTime);
const cue = new Cue(startTime, endTime, '');
cue.value = frame;
if (type) {
cue.type = type;
}
this.id3Track.addCue(cue);
frameTypesAdded[frame.key] = null;
updateCueRanges = true;
}
}
}
}
if (updateCueRanges) {
this.updateId3CueEnds(frameTypesAdded);
}
}
updateId3CueEnds(frameTypesAdded: Record<string, number | null>) {
// Update endTime of previous cue with same IDR frame.type (Ex: TXXX cue spans to next TXXX)
updateId3CueEnds(startTime: number) {
const cues = this.id3Track?.cues;
if (cues) {
for (let i = cues.length; i--; ) {
const cue = cues[i] as any;
const frameType = cue.value?.key;
if (frameType && frameType in frameTypesAdded) {
const startTime = frameTypesAdded[frameType];
if (startTime && cue.endTime !== startTime) {
cue.endTime = startTime;
}
frameTypesAdded[frameType] = cue.startTime;
if (cue.startTime < startTime && cue.endTime === MAX_CUE_ENDTIME) {
cue.endTime = startTime;
}
}
}
@ -290,7 +294,6 @@ class ID3TrackController implements ComponentAPI {
const dateTimeOffset =
(lastFragment.programDateTime as number) / 1000 - lastFragment.start;
const maxCueTime = details.edge || lastFragment.end;
const Cue = getCueClass();
for (let i = 0; i < ids.length; i++) {
@ -303,7 +306,7 @@ class ID3TrackController implements ComponentAPI {
dateRange.startDate,
dateTimeOffset
);
let endTime = maxCueTime;
let endTime = MAX_CUE_ENDTIME;
const endDate = dateRange.endDate;
if (endDate) {
endTime = dateRangeDateToTimelineSeconds(endDate, dateTimeOffset);

View file

@ -11,7 +11,7 @@ import {
ErrorData,
LevelSwitchingData,
} from '../types/events';
import { Level } from '../types/level';
import { HdcpLevel, HdcpLevels, Level } from '../types/level';
import { Events } from '../events';
import { ErrorTypes, ErrorDetails } from '../errors';
import { isCodecSupportedInMp4 } from '../utils/codecs';
@ -162,8 +162,27 @@ export default class LevelController extends BasePlaylistController {
if (levels.length > 0) {
// start bitrate is the first bitrate of the manifest
bitrateStart = levels[0].bitrate;
// sort level on bitrate
levels.sort((a, b) => a.bitrate - b.bitrate);
// sort levels from lowest to highest
levels.sort((a, b) => {
if (a.attrs['HDCP-LEVEL'] !== b.attrs['HDCP-LEVEL']) {
return (a.attrs['HDCP-LEVEL'] || '') > (b.attrs['HDCP-LEVEL'] || '')
? 1
: -1;
}
if (a.bitrate !== b.bitrate) {
return a.bitrate - b.bitrate;
}
if (a.attrs.SCORE !== b.attrs.SCORE) {
return (
a.attrs.decimalFloatingPoint('SCORE') -
b.attrs.decimalFloatingPoint('SCORE')
);
}
if (resolutionFound && a.height !== b.height) {
return a.height - b.height;
}
return 0;
});
this._levels = levels;
// find index of first level in sorted levels
for (let i = 0; i < levels.length; i++) {
@ -183,6 +202,8 @@ export default class LevelController extends BasePlaylistController {
levels,
audioTracks,
subtitleTracks,
sessionData: data.sessionData,
sessionKeys: data.sessionKeys,
firstLevel: this._firstLevel,
stats: data.stats,
audio: audioCodecFound,
@ -362,6 +383,28 @@ export default class LevelController extends BasePlaylistController {
}
}
break;
case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED: {
const restrictedHdcpLevel = level.attrs['HDCP-LEVEL'];
if (restrictedHdcpLevel) {
this.hls.maxHdcpLevel =
HdcpLevels[
HdcpLevels.indexOf(restrictedHdcpLevel as HdcpLevel) - 1
];
this.warn(
`Restricting playback to HDCP-LEVEL of "${this.hls.maxHdcpLevel}" or lower`
);
}
}
// eslint-disable-next-line no-fallthrough
case ErrorDetails.FRAG_PARSING_ERROR:
case ErrorDetails.KEY_SYSTEM_NO_SESSION:
levelIndex =
data.frag?.type === PlaylistLevelType.MAIN
? data.frag.level
: this.currentLevelIndex;
// Do not retry level. Escalate to fatal if switching levels fails.
data.levelRetry = false;
break;
case ErrorDetails.LEVEL_LOAD_ERROR:
case ErrorDetails.LEVEL_LOAD_TIMEOUT:
// Do not perform level switch if an error occurred using delivery directives
@ -435,6 +478,9 @@ export default class LevelController extends BasePlaylistController {
this.warn(`${errorDetails}: switch to ${nextLevel}`);
errorEvent.levelRetry = true;
this.hls.nextAutoLevel = nextLevel;
} else if (errorEvent.levelRetry === false) {
// No levels to switch to and no more retries
errorEvent.fatal = true;
}
}
}
@ -518,6 +564,7 @@ export default class LevelController extends BasePlaylistController {
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters) {
super.loadPlaylist();
const level = this.currentLevelIndex;
const currentLevel = this._levels[level];
@ -536,7 +583,7 @@ export default class LevelController extends BasePlaylistController {
this.log(
`Attempt loading level index ${level}${
hlsUrlParameters
hlsUrlParameters?.msn !== undefined
? ' at sn ' +
hlsUrlParameters.msn +
' part ' +

View file

@ -7,7 +7,6 @@ import { logger } from '../utils/logger';
import { Fragment, Part } from '../loader/fragment';
import { LevelDetails } from '../loader/level-details';
import type { Level } from '../types/level';
import type { LoaderStats } from '../types/loader';
import type { MediaPlaylist } from '../types/media-playlist';
import { DateRange } from '../loader/date-range';
@ -123,7 +122,6 @@ export function updateFragPTSDTS(
frag.duration = endPTS - startPTS;
const drift = startPTS - frag.start;
frag.appendedPTS = endPTS;
frag.start = frag.startPTS = startPTS;
frag.maxStartPTS = maxStartPTS;
frag.startDTS = startDTS;
@ -434,61 +432,33 @@ export function addSliding(details: LevelDetails, start: number) {
export function computeReloadInterval(
newDetails: LevelDetails,
stats: LoaderStats
distanceToLiveEdgeMs: number = Infinity
): number {
const reloadInterval = 1000 * newDetails.levelTargetDuration;
const reloadIntervalAfterMiss = reloadInterval / 2;
const timeSinceLastModified = newDetails.age;
const useLastModified =
timeSinceLastModified > 0 && timeSinceLastModified < reloadInterval * 3;
const roundTrip = stats.loading.end - stats.loading.start;
let reloadInterval = 1000 * newDetails.targetduration;
let estimatedTimeUntilUpdate;
let availabilityDelay = newDetails.availabilityDelay;
// let estimate = 'average';
if (newDetails.updated === false) {
if (useLastModified) {
// estimate = 'miss round trip';
// We should have had a hit so try again in the time it takes to get a response,
// but no less than 1/3 second.
const minRetry = 333 * newDetails.misses;
estimatedTimeUntilUpdate = Math.max(
Math.min(reloadIntervalAfterMiss, roundTrip * 2),
minRetry
);
newDetails.availabilityDelay =
(newDetails.availabilityDelay || 0) + estimatedTimeUntilUpdate;
} else {
// estimate = 'miss half average';
// follow HLS Spec, If the client reloads a Playlist file and finds that it has not
// changed then it MUST wait for a period of one-half the target
// duration before retrying.
estimatedTimeUntilUpdate = reloadIntervalAfterMiss;
if (newDetails.updated) {
// Use last segment duration when shorter than target duration and near live edge
const fragments = newDetails.fragments;
const liveEdgeMaxTargetDurations = 4;
if (
fragments.length &&
reloadInterval * liveEdgeMaxTargetDurations > distanceToLiveEdgeMs
) {
const lastSegmentDuration =
fragments[fragments.length - 1].duration * 1000;
if (lastSegmentDuration < reloadInterval) {
reloadInterval = lastSegmentDuration;
}
}
} else if (useLastModified) {
// estimate = 'next modified date';
// Get the closest we've been to timeSinceLastModified on update
availabilityDelay = Math.min(
availabilityDelay || reloadInterval / 2,
timeSinceLastModified
);
newDetails.availabilityDelay = availabilityDelay;
estimatedTimeUntilUpdate =
availabilityDelay + reloadInterval - timeSinceLastModified;
} else {
estimatedTimeUntilUpdate = reloadInterval - roundTrip;
// estimate = 'miss half average';
// follow HLS Spec, If the client reloads a Playlist file and finds that it has not
// changed then it MUST wait for a period of one-half the target
// duration before retrying.
reloadInterval /= 2;
}
// console.log(`[computeReloadInterval] live reload ${newDetails.updated ? 'REFRESHED' : 'MISSED'}`,
// '\n method', estimate,
// '\n estimated time until update =>', estimatedTimeUntilUpdate,
// '\n average target duration', reloadInterval,
// '\n time since modified', timeSinceLastModified,
// '\n time round trip', roundTrip,
// '\n availability delay', availabilityDelay);
return Math.round(estimatedTimeUntilUpdate);
return Math.round(reloadInterval);
}
export function getFragmentWithSN(

View file

@ -1,20 +1,21 @@
import BaseStreamController, { State } from './base-stream-controller';
import { changeTypeSupported } from '../is-supported';
import type { NetworkComponentAPI } from '../types/component-api';
import { Events } from '../events';
import { BufferHelper } from '../utils/buffer-helper';
import type { FragmentTracker } from './fragment-tracker';
import { BufferHelper, BufferInfo } from '../utils/buffer-helper';
import { FragmentState } from './fragment-tracker';
import type { Level } from '../types/level';
import { PlaylistLevelType } from '../types/loader';
import { ElementaryStreamTypes, Fragment } from '../loader/fragment';
import TransmuxerInterface from '../demux/transmuxer-interface';
import type { TransmuxerResult } from '../types/transmuxer';
import { ChunkMetadata } from '../types/transmuxer';
import GapController from './gap-controller';
import { ErrorDetails } from '../errors';
import { ErrorDetails, ErrorTypes } from '../errors';
import type { NetworkComponentAPI } from '../types/component-api';
import type Hls from '../hls';
import type { Level } from '../types/level';
import type { LevelDetails } from '../loader/level-details';
import type { FragmentTracker } from './fragment-tracker';
import type KeyLoader from '../loader/key-loader';
import type { TransmuxerResult } from '../types/transmuxer';
import type { TrackSet } from '../types/track';
import type { SourceBufferName } from '../types/buffer';
import type {
@ -56,8 +57,12 @@ export default class StreamController
private audioCodecSwitch: boolean = false;
private videoBuffer: any | null = null;
constructor(hls: Hls, fragmentTracker: FragmentTracker) {
super(hls, fragmentTracker, '[stream-controller]');
constructor(
hls: Hls,
fragmentTracker: FragmentTracker,
keyLoader: KeyLoader
) {
super(hls, fragmentTracker, keyLoader, '[stream-controller]');
this._registerListeners();
}
@ -228,6 +233,24 @@ export default class StreamController
const levelInfo = levels[level];
// if buffer length is less than maxBufLen try to load a new fragment
const bufferInfo = this.getMainFwdBufferInfo();
if (bufferInfo === null) {
return;
}
const lastDetails = this.getLevelDetails();
if (lastDetails && this._streamEnded(bufferInfo, lastDetails)) {
const data: BufferEOSData = {};
if (this.altAudio) {
data.type = 'video';
}
this.hls.trigger(Events.BUFFER_EOS, data);
this.state = State.ENDED;
return;
}
// set next load level : this will trigger a playlist load if needed
this.level = hls.nextLoadLevel = level;
@ -240,14 +263,11 @@ export default class StreamController
this.state === State.WAITING_LEVEL ||
(levelDetails.live && this.levelLastLoaded !== level)
) {
this.level = level;
this.state = State.WAITING_LEVEL;
return;
}
const bufferInfo = this.getMainFwdBufferInfo();
if (bufferInfo === null) {
return;
}
const bufferLen = bufferInfo.len;
// compute max Buffer Length that we could get from this load level, based on level bitrate. don't buffer more than 60 MB and more than 30s
@ -258,17 +278,6 @@ export default class StreamController
return;
}
if (this._streamEnded(bufferInfo, levelDetails)) {
const data: BufferEOSData = {};
if (this.altAudio) {
data.type = 'video';
}
this.hls.trigger(Events.BUFFER_EOS, data);
this.state = State.ENDED;
return;
}
if (
this.backtrackFragment &&
this.backtrackFragment.start > bufferInfo.end
@ -308,8 +317,12 @@ export default class StreamController
this.audioOnly && !this.altAudio
? ElementaryStreamTypes.AUDIO
: ElementaryStreamTypes.VIDEO;
if (media) {
this.afterBufferFlushed(media, type, PlaylistLevelType.MAIN);
const mediaBuffer =
(type === ElementaryStreamTypes.VIDEO
? this.videoBuffer
: this.mediaBuffer) || this.media;
if (mediaBuffer) {
this.afterBufferFlushed(mediaBuffer, type, PlaylistLevelType.MAIN);
}
frag = this.getNextFragment(this.nextLoadPosition, levelDetails);
}
@ -320,13 +333,7 @@ export default class StreamController
frag = frag.initSegment;
}
// We want to load the key if we're dealing with an identity key, because we will decrypt
// this content using the key we fetch. Other keys will be handled by the DRM CDM via EME.
if (frag.decryptdata?.keyFormat === 'identity' && !frag.decryptdata?.key) {
this.loadKey(frag, levelDetails);
} else {
this.loadFragment(frag, levelDetails, targetBufferTime);
}
this.loadFragment(frag, levelDetails, targetBufferTime);
}
protected loadFragment(
@ -339,12 +346,12 @@ export default class StreamController
this.fragCurrent = frag;
if (fragState === FragmentState.NOT_LOADED) {
if (frag.sn === 'initSegment') {
this._loadInitSegment(frag);
this._loadInitSegment(frag, levelDetails);
} else if (this.bitrateTest) {
this.log(
`Fragment ${frag.sn} of level ${frag.level} is being downloaded to test bitrate and will not be buffered`
);
this._loadBitrateTestFrag(frag);
this._loadBitrateTestFrag(frag, levelDetails);
} else {
this.startFragRequested = true;
super.loadFragment(frag, levelDetails, targetBufferTime);
@ -465,8 +472,8 @@ export default class StreamController
const fragCurrent = this.fragCurrent;
this.fragCurrent = null;
this.backtrackFragment = null;
if (fragCurrent?.loader) {
fragCurrent.loader.abort();
if (fragCurrent) {
fragCurrent.abortRequests();
}
switch (this.state) {
case State.KEY_LOADING:
@ -618,7 +625,7 @@ export default class StreamController
if (fragCurrent.level !== data.level && fragCurrent.loader) {
this.state = State.IDLE;
this.backtrackFragment = null;
fragCurrent.loader.abort();
fragCurrent.abortRequests();
}
}
@ -740,9 +747,9 @@ export default class StreamController
this.mediaBuffer = this.media;
const fragCurrent = this.fragCurrent;
// we need to refill audio buffer from main: cancel any frag loading to speed up audio switch
if (fragCurrent?.loader) {
if (fragCurrent) {
this.log('Switching to main audio track, cancel main fragment load');
fragCurrent.loader.abort();
fragCurrent.abortRequests();
}
// destroy transmuxer to force init segment generation (following audio switch)
this.resetTransmuxer();
@ -850,9 +857,14 @@ export default class StreamController
}
private onError(event: Events.ERROR, data: ErrorData) {
if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) {
this.onFragmentOrKeyLoadError(PlaylistLevelType.MAIN, data);
return;
}
switch (data.details) {
case ErrorDetails.FRAG_LOAD_ERROR:
case ErrorDetails.FRAG_LOAD_TIMEOUT:
case ErrorDetails.FRAG_PARSING_ERROR:
case ErrorDetails.KEY_LOAD_ERROR:
case ErrorDetails.KEY_LOAD_TIMEOUT:
this.onFragmentOrKeyLoadError(PlaylistLevelType.MAIN, data);
@ -942,11 +954,11 @@ export default class StreamController
type !== ElementaryStreamTypes.AUDIO ||
(this.audioOnly && !this.altAudio)
) {
const media =
const mediaBuffer =
(type === ElementaryStreamTypes.VIDEO
? this.videoBuffer
: this.mediaBuffer) || this.media;
this.afterBufferFlushed(media, type, PlaylistLevelType.MAIN);
this.afterBufferFlushed(mediaBuffer, type, PlaylistLevelType.MAIN);
}
}
@ -1013,11 +1025,11 @@ export default class StreamController
return audioCodec;
}
private _loadBitrateTestFrag(frag: Fragment) {
private _loadBitrateTestFrag(frag: Fragment, levelDetails: LevelDetails) {
frag.bitrateTest = true;
this._doFragLoad(frag).then((data) => {
this._doFragLoad(frag, levelDetails).then((data) => {
const { hls } = this;
if (!data || hls.nextLoadLevel || this.fragContextChanged(frag)) {
if (!data || this.fragContextChanged(frag)) {
return;
}
this.fragLoadError = 0;
@ -1094,7 +1106,7 @@ export default class StreamController
endDTS,
};
} else {
if (video.firstKeyFrame && video.independent) {
if (video.firstKeyFrame && video.independent && chunkMeta.id === 1) {
this.couldBacktrack = true;
}
if (video.dropped && video.independent) {
@ -1276,7 +1288,7 @@ export default class StreamController
this.tick();
}
private getMainFwdBufferInfo() {
public getMainFwdBufferInfo(): BufferInfo | null {
return this.getFwdBufferInfo(
this.mediaBuffer ? this.mediaBuffer : this.media,
PlaylistLevelType.MAIN
@ -1327,13 +1339,13 @@ export default class StreamController
fragPlaying.level !== fragCurrentLevel ||
fragPlayingCurrent.urlId !== fragPlaying.urlId
) {
this.fragPlaying = fragPlayingCurrent;
this.hls.trigger(Events.FRAG_CHANGED, { frag: fragPlayingCurrent });
if (!fragPlaying || fragPlaying.level !== fragCurrentLevel) {
this.hls.trigger(Events.LEVEL_SWITCHED, {
level: fragCurrentLevel,
});
}
this.fragPlaying = fragPlayingCurrent;
}
}
}

View file

@ -7,9 +7,10 @@ import { FragmentState } from './fragment-tracker';
import BaseStreamController, { State } from './base-stream-controller';
import { PlaylistLevelType } from '../types/loader';
import { Level } from '../types/level';
import type { FragmentTracker } from './fragment-tracker';
import type { NetworkComponentAPI } from '../types/component-api';
import type Hls from '../hls';
import type { FragmentTracker } from './fragment-tracker';
import type KeyLoader from '../loader/key-loader';
import type { LevelDetails } from '../loader/level-details';
import type { Fragment } from '../loader/fragment';
import type {
@ -21,6 +22,7 @@ import type {
TrackSwitchedData,
BufferFlushingData,
LevelLoadedData,
FragBufferedData,
} from '../types/events';
const TICK_INTERVAL = 500; // how often to tick in ms
@ -40,8 +42,12 @@ export class SubtitleStreamController
private tracksBuffered: Array<TimeRange[]> = [];
private mainDetails: LevelDetails | null = null;
constructor(hls: Hls, fragmentTracker: FragmentTracker) {
super(hls, fragmentTracker, '[subtitle-stream-controller]');
constructor(
hls: Hls,
fragmentTracker: FragmentTracker,
keyLoader: KeyLoader
) {
super(hls, fragmentTracker, keyLoader, '[subtitle-stream-controller]');
this._registerListeners();
}
@ -62,6 +68,7 @@ export class SubtitleStreamController
hls.on(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.on(Events.SUBTITLE_FRAG_PROCESSED, this.onSubtitleFragProcessed, this);
hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this);
}
private _unregisterListeners() {
@ -76,13 +83,20 @@ export class SubtitleStreamController
hls.off(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.off(Events.SUBTITLE_FRAG_PROCESSED, this.onSubtitleFragProcessed, this);
hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this);
}
startLoad() {
startLoad(startPosition: number) {
this.stopLoad();
this.state = State.IDLE;
this.setInterval(TICK_INTERVAL);
this.nextLoadPosition =
this.startPosition =
this.lastCurrentTime =
startPosition;
this.tick();
}
@ -174,6 +188,14 @@ export class SubtitleStreamController
}
}
onFragBuffered(event: Events.FRAG_BUFFERED, data: FragBufferedData) {
if (!this.loadedmetadata && data.frag.type === PlaylistLevelType.MAIN) {
if (this.media?.buffered.length) {
this.loadedmetadata = true;
}
}
}
// If something goes wrong, proceed to next frag, if we were processing one.
onError(event: Events.ERROR, data: ErrorData) {
const frag = data.frag;
@ -182,8 +204,8 @@ export class SubtitleStreamController
return;
}
if (this.fragCurrent?.loader) {
this.fragCurrent.loader.abort();
if (this.fragCurrent) {
this.fragCurrent.abortRequests();
}
this.state = State.IDLE;
@ -244,6 +266,7 @@ export class SubtitleStreamController
return;
}
this.mediaBuffer = this.mediaBufferTimeRanges;
let sliding = 0;
if (newDetails.live || track.details?.live) {
const mainDetails = this.mainDetails;
if (newDetails.deltaUpdateFailed || !mainDetails) {
@ -253,21 +276,28 @@ export class SubtitleStreamController
if (!track.details) {
if (newDetails.hasProgramDateTime && mainDetails.hasProgramDateTime) {
alignMediaPlaylistByPDT(newDetails, mainDetails);
sliding = newDetails.fragments[0].start;
} else if (mainSlidingStartFragment) {
// line up live playlist with main so that fragments in range are loaded
addSliding(newDetails, mainSlidingStartFragment.start);
sliding = mainSlidingStartFragment.start;
addSliding(newDetails, sliding);
}
} else {
const sliding = this.alignPlaylists(newDetails, track.details);
sliding = this.alignPlaylists(newDetails, track.details);
if (sliding === 0 && mainSlidingStartFragment) {
// realign with main when there is no overlap with last refresh
addSliding(newDetails, mainSlidingStartFragment.start);
sliding = mainSlidingStartFragment.start;
addSliding(newDetails, sliding);
}
}
}
track.details = newDetails;
this.levelLastLoaded = trackId;
if (!this.startFragRequested && (this.mainDetails || !newDetails.live)) {
this.setStartPosition(track.details, sliding);
}
// trigger handler right now
this.tick();
@ -311,7 +341,7 @@ export class SubtitleStreamController
const startTime = performance.now();
// decrypt the subtitles
this.decrypter
.webCryptoDecrypt(
.decrypt(
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer
@ -326,6 +356,10 @@ export class SubtitleStreamController
tdecrypt: endTime,
},
});
})
.catch((err) => {
this.warn(`${err.name}: ${err.message}`);
this.state = State.IDLE;
});
}
}
@ -349,15 +383,21 @@ export class SubtitleStreamController
// Expand range of subs loaded by one target-duration in either direction to make up for misaligned playlists
const trackDetails = levels[currentTrackId].details as LevelDetails;
const targetDuration = trackDetails.targetduration;
const { config, media } = this;
const { config } = this;
const currentTime = this.getLoadPosition();
const bufferedInfo = BufferHelper.bufferedInfo(
this.tracksBuffered[this.currentTrackId] || [],
media.currentTime - targetDuration,
currentTime - targetDuration,
config.maxBufferHole
);
const { end: targetBufferTime, len: bufferLen } = bufferedInfo;
const maxBufLen = this.getMaxBufferLength() + targetDuration;
const mainBufferInfo = this.getFwdBufferInfo(
this.media,
PlaylistLevelType.MAIN
);
const maxBufLen =
this.getMaxBufferLength(mainBufferInfo?.len) + targetDuration;
if (bufferLen > maxBufLen) {
return;
@ -371,7 +411,7 @@ export class SubtitleStreamController
const fragLen = fragments.length;
const end = trackDetails.edge;
let foundFrag: Fragment | null;
let foundFrag: Fragment | null = null;
const fragPrevious = this.fragPrevious;
if (targetBufferTime < end) {
const { maxFragLookUpTolerance } = config;
@ -391,27 +431,28 @@ export class SubtitleStreamController
} else {
foundFrag = fragments[fragLen - 1];
}
foundFrag = this.mapToInitFragWhenRequired(foundFrag);
if (!foundFrag) {
return;
}
// only load if fragment is not loaded
foundFrag = this.mapToInitFragWhenRequired(foundFrag) as Fragment;
if (
this.fragmentTracker.getState(foundFrag) !== FragmentState.NOT_LOADED
this.fragmentTracker.getState(foundFrag) === FragmentState.NOT_LOADED
) {
return;
}
if (foundFrag.encrypted) {
this.loadKey(foundFrag, trackDetails);
} else {
// only load if fragment is not loaded
this.loadFragment(foundFrag, trackDetails, targetBufferTime);
}
}
}
protected getMaxBufferLength(mainBufferLength?: number): number {
const maxConfigBuffer = super.getMaxBufferLength();
if (!mainBufferLength) {
return maxConfigBuffer;
}
return Math.max(maxConfigBuffer, mainBufferLength);
}
protected loadFragment(
frag: Fragment,
levelDetails: LevelDetails,
@ -419,8 +460,9 @@ export class SubtitleStreamController
) {
this.fragCurrent = frag;
if (frag.sn === 'initSegment') {
this._loadInitSegment(frag);
this._loadInitSegment(frag, levelDetails);
} else {
this.startFragRequested = true;
super.loadFragment(frag, levelDetails, targetBufferTime);
}
}

View file

@ -275,6 +275,7 @@ class SubtitleTrackController extends BasePlaylistController {
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
super.loadPlaylist();
const currentTrack = this.tracksInGroup[this.trackId];
if (this.shouldLoadTrack(currentTrack)) {
const id = currentTrack.id;

View file

@ -442,6 +442,11 @@ export class TimelineController implements ComponentAPI {
}
}
private closedCaptionsForLevel(frag: Fragment): string | undefined {
const level = this.hls.levels[frag.level];
return level?.attrs['CLOSED-CAPTIONS'];
}
private onFragLoading(event: Events.FRAG_LOADING, data: FragLoadingData) {
const { cea608Parser1, cea608Parser2, lastSn, lastPartIndex } = this;
if (!this.enabled || !(cea608Parser1 && cea608Parser2)) {
@ -492,12 +497,7 @@ export class TimelineController implements ComponentAPI {
// fragment after decryption has a stats object
const decrypted = 'stats' in data;
// If the subtitles are not encrypted, parse VTTs now. Otherwise, we need to wait.
if (
decryptData == null ||
decryptData.key == null ||
decryptData.method !== 'AES-128' ||
decrypted
) {
if (decryptData == null || !decryptData.encrypted || decrypted) {
const trackPlaylistMedia = this.tracks[frag.level];
const vttCCs = this.vttCCs;
if (!vttCCs[frag.cc]) {
@ -654,14 +654,21 @@ export class TimelineController implements ComponentAPI {
return;
}
const { frag, samples } = data;
if (
frag.type === PlaylistLevelType.MAIN &&
this.closedCaptionsForLevel(frag) === 'NONE'
) {
return;
}
// If the event contains captions (found in the bytes property), push all bytes into the parser immediately
// It will create the proper timestamps based on the PTS value
for (let i = 0; i < data.samples.length; i++) {
const ccBytes = data.samples[i].bytes;
for (let i = 0; i < samples.length; i++) {
const ccBytes = samples[i].bytes;
if (ccBytes) {
const ccdatas = this.extractCea608Data(ccBytes);
cea608Parser1.addData(data.samples[i].pts, ccdatas[0]);
cea608Parser2.addData(data.samples[i].pts, ccdatas[1]);
cea608Parser1.addData(samples[i].pts, ccdatas[0]);
cea608Parser2.addData(samples[i].pts, ccdatas[1]);
}
}
}

View file

@ -1,8 +1,8 @@
export default class AESCrypto {
private subtle: SubtleCrypto;
private aesIV: ArrayBuffer;
private aesIV: Uint8Array;
constructor(subtle: SubtleCrypto, iv: ArrayBuffer) {
constructor(subtle: SubtleCrypto, iv: Uint8Array) {
this.subtle = subtle;
this.aesIV = iv;
}

View file

@ -5,14 +5,11 @@ import { logger } from '../utils/logger';
import { appendUint8Array } from '../utils/mp4-tools';
import { sliceUint8 } from '../utils/typed-array';
import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
const CHUNK_SIZE = 16; // 16 bytes, 128 bits
export default class Decrypter {
private logEnabled: boolean = true;
private observer: HlsEventEmitter;
private config: HlsConfig;
private removePKCS7Padding: boolean;
private subtle: SubtleCrypto | null = null;
private softwareDecrypter: AESDecryptor | null = null;
@ -21,14 +18,10 @@ export default class Decrypter {
private remainderData: Uint8Array | null = null;
private currentIV: ArrayBuffer | null = null;
private currentResult: ArrayBuffer | null = null;
private useSoftware: boolean;
constructor(
observer: HlsEventEmitter,
config: HlsConfig,
{ removePKCS7Padding = true } = {}
) {
this.observer = observer;
this.config = config;
constructor(config: HlsConfig, { removePKCS7Padding = true } = {}) {
this.useSoftware = config.enableSoftwareAES;
this.removePKCS7Padding = removePKCS7Padding;
// built in decryptor expects PKCS7 padding
if (removePKCS7Padding) {
@ -44,24 +37,29 @@ export default class Decrypter {
}
}
if (this.subtle === null) {
this.config.enableSoftwareAES = true;
this.useSoftware = true;
}
}
destroy() {
// @ts-ignore
this.observer = null;
this.subtle = null;
this.softwareDecrypter = null;
this.key = null;
this.fastAesKey = null;
this.remainderData = null;
this.currentIV = null;
this.currentResult = null;
}
public isSync() {
return this.config.enableSoftwareAES;
return this.useSoftware;
}
public flush(): Uint8Array | void {
const { currentResult } = this;
if (!currentResult) {
public flush(): Uint8Array | null {
const { currentResult, remainderData } = this;
if (!currentResult || remainderData) {
this.reset();
return;
return null;
}
const data = new Uint8Array(currentResult);
this.reset();
@ -83,20 +81,24 @@ export default class Decrypter {
public decrypt(
data: Uint8Array | ArrayBuffer,
key: ArrayBuffer,
iv: ArrayBuffer,
callback: (decryptedData: ArrayBuffer) => void
) {
if (this.config.enableSoftwareAES) {
this.softwareDecrypt(new Uint8Array(data), key, iv);
const decryptResult = this.flush();
if (decryptResult) {
callback(decryptResult.buffer);
}
} else {
this.webCryptoDecrypt(new Uint8Array(data), key, iv).then(callback);
iv: ArrayBuffer
): Promise<ArrayBuffer> {
if (this.useSoftware) {
return new Promise((resolve, reject) => {
this.softwareDecrypt(new Uint8Array(data), key, iv);
const decryptResult = this.flush();
if (decryptResult) {
resolve(decryptResult.buffer);
} else {
reject(new Error('[softwareDecrypt] Failed to decrypt data'));
}
});
}
return this.webCryptoDecrypt(new Uint8Array(data), key, iv);
}
// Software decryption is progressive. Progressive decryption may not return a result on each call. Any cached
// data is handled in the flush() call
public softwareDecrypt(
data: Uint8Array,
key: ArrayBuffer,
@ -158,20 +160,28 @@ export default class Decrypter {
if (!subtle) {
return Promise.reject(new Error('web crypto not initialized'));
}
const crypto = new AESCrypto(subtle, iv);
this.logOnce('WebCrypto AES decrypt');
const crypto = new AESCrypto(subtle, new Uint8Array(iv));
return crypto.decrypt(data.buffer, aesKey);
})
.catch((err) => {
return this.onWebCryptoError(err, data, key, iv) as ArrayBuffer;
logger.warn(
`[decrypter]: WebCrypto Error, disable WebCrypto API, ${err.name}: ${err.message}`
);
return this.onWebCryptoError(data, key, iv);
});
}
private onWebCryptoError(err, data, key, iv): ArrayBuffer | null {
logger.warn('[decrypter.ts]: WebCrypto Error, disable WebCrypto API:', err);
this.config.enableSoftwareAES = true;
private onWebCryptoError(data, key, iv): ArrayBuffer | never {
this.useSoftware = true;
this.logEnabled = true;
return this.softwareDecrypt(data, key, iv);
this.softwareDecrypt(data, key, iv);
const decryptResult = this.flush();
if (decryptResult) {
return decryptResult.buffer;
}
throw new Error('WebCrypto and softwareDecrypt: failed to decrypt data');
}
private getValidChunk(data: Uint8Array): Uint8Array {
@ -188,7 +198,7 @@ export default class Decrypter {
if (!this.logEnabled) {
return;
}
logger.log(`[decrypter.ts]: ${msg}`);
logger.log(`[decrypter]: ${msg}`);
this.logEnabled = false;
}
}

View file

@ -33,18 +33,18 @@ export function getAudioConfig(
): AudioConfig | void {
let adtsObjectType: number;
let adtsExtensionSamplingIndex: number;
let adtsChanelConfig: number;
let adtsChannelConfig: number;
let config: number[];
const userAgent = navigator.userAgent.toLowerCase();
const manifestCodec = audioCodec;
const adtsSampleingRates = [
const adtsSamplingRates = [
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025,
8000, 7350,
];
// byte 2
adtsObjectType = ((data[offset + 2] & 0xc0) >>> 6) + 1;
const adtsSamplingIndex = (data[offset + 2] & 0x3c) >>> 2;
if (adtsSamplingIndex > adtsSampleingRates.length - 1) {
if (adtsSamplingIndex > adtsSamplingRates.length - 1) {
observer.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
@ -53,9 +53,9 @@ export function getAudioConfig(
});
return;
}
adtsChanelConfig = (data[offset + 2] & 0x01) << 2;
adtsChannelConfig = (data[offset + 2] & 0x01) << 2;
// byte 3
adtsChanelConfig |= (data[offset + 3] & 0xc0) >>> 6;
adtsChannelConfig |= (data[offset + 3] & 0xc0) >>> 6;
logger.log(
`manifest codec:${audioCodec}, ADTS type:${adtsObjectType}, samplingIndex:${adtsSamplingIndex}`
);
@ -101,9 +101,9 @@ export function getAudioConfig(
if (
(audioCodec &&
audioCodec.indexOf('mp4a.40.2') !== -1 &&
((adtsSamplingIndex >= 6 && adtsChanelConfig === 1) ||
((adtsSamplingIndex >= 6 && adtsChannelConfig === 1) ||
/vivaldi/i.test(userAgent))) ||
(!audioCodec && adtsChanelConfig === 1)
(!audioCodec && adtsChannelConfig === 1)
) {
adtsObjectType = 2;
config = new Array(2);
@ -150,9 +150,9 @@ export function getAudioConfig(
config[0] |= (adtsSamplingIndex & 0x0e) >> 1;
config[1] |= (adtsSamplingIndex & 0x01) << 7;
// channelConfiguration
config[1] |= adtsChanelConfig << 3;
config[1] |= adtsChannelConfig << 3;
if (adtsObjectType === 5) {
// adtsExtensionSampleingIndex
// adtsExtensionSamplingIndex
config[1] |= (adtsExtensionSamplingIndex & 0x0e) >> 1;
config[2] = (adtsExtensionSamplingIndex & 0x01) << 7;
// adtsObjectType (force to 2, chrome is checking that object type is less than 5 ???
@ -162,8 +162,8 @@ export function getAudioConfig(
}
return {
config,
samplerate: adtsSampleingRates[adtsSamplingIndex],
channelCount: adtsChanelConfig,
samplerate: adtsSamplingRates[adtsSamplingIndex],
channelCount: adtsChannelConfig,
codec: 'mp4a.40.' + adtsObjectType,
manifestCodec,
};

View file

@ -21,6 +21,7 @@ class BaseAudioDemuxer implements Demuxer {
protected cachedData: Uint8Array | null = null;
protected basePTS: number | null = null;
protected initPTS: number | null = null;
protected lastPTS: number | null = null;
resetInitSegment(
initSegment: Uint8Array | undefined,
@ -46,6 +47,7 @@ class BaseAudioDemuxer implements Demuxer {
resetContiguity(): void {
this.basePTS = null;
this.lastPTS = null;
this.frameIndex = 0;
}
@ -69,7 +71,6 @@ class BaseAudioDemuxer implements Demuxer {
let id3Data: Uint8Array | undefined = ID3.getID3Data(data, 0);
let offset = id3Data ? id3Data.length : 0;
let lastDataIndex;
let pts;
const track = this._audioTrack;
const id3Track = this._id3Track;
const timestamp = id3Data ? ID3.getTimeStamp(id3Data) : undefined;
@ -80,26 +81,30 @@ class BaseAudioDemuxer implements Demuxer {
(this.frameIndex === 0 && Number.isFinite(timestamp))
) {
this.basePTS = initPTSFn(timestamp, timeOffset, this.initPTS);
this.lastPTS = this.basePTS;
}
if (this.lastPTS === null) {
this.lastPTS = this.basePTS;
}
// more expressive than alternative: id3Data?.length
if (id3Data && id3Data.length > 0) {
id3Track.samples.push({
pts: this.basePTS,
dts: this.basePTS,
pts: this.lastPTS,
dts: this.lastPTS,
data: id3Data,
type: MetadataSchema.audioId3,
duration: Number.POSITIVE_INFINITY,
});
}
pts = this.basePTS;
while (offset < length) {
if (this.canParse(data, offset)) {
const frame = this.appendFrame(track, data, offset);
if (frame) {
this.frameIndex++;
pts = frame.sample.pts;
this.lastPTS = frame.sample.pts;
offset += frame.length;
lastDataIndex = offset;
} else {
@ -109,10 +114,11 @@ class BaseAudioDemuxer implements Demuxer {
// after a ID3.canParse, a call to ID3.getID3Data *should* always returns some data
id3Data = ID3.getID3Data(data, offset)!;
id3Track.samples.push({
pts: pts,
dts: pts,
pts: this.lastPTS,
dts: this.lastPTS,
data: id3Data,
type: MetadataSchema.audioId3,
duration: Number.POSITIVE_INFINITY,
});
offset += id3Data.length;
lastDataIndex = offset;

View file

@ -41,13 +41,14 @@ class ExpGolomb {
// (count:int):void
skipBits(count: number): void {
let skipBytes; // :int
count = Math.min(count, this.bytesAvailable * 8 + this.bitsAvailable);
if (this.bitsAvailable > count) {
this.word <<= count;
this.bitsAvailable -= count;
} else {
count -= this.bitsAvailable;
skipBytes = count >> 3;
count -= skipBytes >> 3;
count -= skipBytes << 3;
this.bytesAvailable -= skipBytes;
this.loadWord();
this.word <<= count;
@ -68,6 +69,8 @@ class ExpGolomb {
this.word <<= bits;
} else if (this.bytesAvailable > 0) {
this.loadWord();
} else {
throw new Error('no bits available');
}
bits = size - bits;

View file

@ -42,12 +42,11 @@ class MP4Demuxer implements Demuxer {
public resetTimeStamp() {}
public resetInitSegment(
initSegment: Uint8Array,
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
trackDuration: number
) {
const initData = parseInitSegment(initSegment);
const videoTrack = (this.videoTrack = dummyTrack(
'video',
1
@ -64,6 +63,11 @@ class MP4Demuxer implements Demuxer {
this.id3Track = dummyTrack('id3', 1) as DemuxedMetadataTrack;
this.timeOffset = 0;
if (!initSegment || !initSegment.byteLength) {
return;
}
const initData = parseInitSegment(initSegment);
if (initData.video) {
const { id, timescale, codec } = initData.video;
videoTrack.id = id;
@ -155,6 +159,14 @@ class MP4Demuxer implements Demuxer {
? emsgInfo.presentationTime! / emsgInfo.timeScale
: timeOffset +
emsgInfo.presentationTimeDelta! / emsgInfo.timeScale;
let duration =
emsgInfo.eventDuration === 0xffffffff
? Number.POSITIVE_INFINITY
: emsgInfo.eventDuration / emsgInfo.timeScale;
// Safari takes anything <= 0.001 seconds and maps it to Infinity
if (duration <= 0.001) {
duration = Number.POSITIVE_INFINITY;
}
const payload = emsgInfo.payload;
id3Track.samples.push({
data: payload,
@ -162,6 +174,7 @@ class MP4Demuxer implements Demuxer {
dts: pts,
pts: pts,
type: MetadataSchema.emsg,
duration: duration,
});
}
});

View file

@ -12,7 +12,7 @@ import type {
DemuxedVideoTrack,
KeyData,
} from '../types/demuxer';
import { discardEPB } from './tsdemuxer';
import { discardEPB } from '../utils/mp4-tools';
class SampleAesDecrypter {
private keyData: KeyData;
@ -20,20 +20,16 @@ class SampleAesDecrypter {
constructor(observer: HlsEventEmitter, config: HlsConfig, keyData: KeyData) {
this.keyData = keyData;
this.decrypter = new Decrypter(observer, config, {
this.decrypter = new Decrypter(config, {
removePKCS7Padding: false,
});
}
decryptBuffer(
encryptedData: Uint8Array | ArrayBuffer,
callback: (decryptedData: ArrayBuffer) => void
) {
this.decrypter.decrypt(
decryptBuffer(encryptedData: Uint8Array | ArrayBuffer): Promise<ArrayBuffer> {
return this.decrypter.decrypt(
encryptedData,
this.keyData.key.buffer,
this.keyData.iv.buffer,
callback
this.keyData.iv.buffer
);
}
@ -41,8 +37,7 @@ class SampleAesDecrypter {
private decryptAacSample(
samples: AudioSample[],
sampleIndex: number,
callback: () => void,
sync: boolean
callback: () => void
) {
const curUnit = samples[sampleIndex].unit;
if (curUnit.length <= 16) {
@ -59,13 +54,12 @@ class SampleAesDecrypter {
encryptedData.byteOffset + encryptedData.length
);
const localthis = this;
this.decryptBuffer(encryptedBuffer, (decryptedBuffer: ArrayBuffer) => {
this.decryptBuffer(encryptedBuffer).then((decryptedBuffer: ArrayBuffer) => {
const decryptedData = new Uint8Array(decryptedBuffer);
curUnit.set(decryptedData, 16);
if (!sync) {
localthis.decryptAacSamples(samples, sampleIndex + 1, callback);
if (!this.decrypter.isSync()) {
this.decryptAacSamples(samples, sampleIndex + 1, callback);
}
});
}
@ -85,11 +79,9 @@ class SampleAesDecrypter {
continue;
}
const sync = this.decrypter.isSync();
this.decryptAacSample(samples, sampleIndex, callback);
this.decryptAacSample(samples, sampleIndex, callback, sync);
if (!sync) {
if (!this.decrypter.isSync()) {
return;
}
}
@ -140,28 +132,17 @@ class SampleAesDecrypter {
sampleIndex: number,
unitIndex: number,
callback: () => void,
curUnit: AvcSampleUnit,
sync: boolean
curUnit: AvcSampleUnit
) {
const decodedData = discardEPB(curUnit.data);
const encryptedData = this.getAvcEncryptedData(decodedData);
const localthis = this;
this.decryptBuffer(
encryptedData.buffer,
function (decryptedBuffer: ArrayBuffer) {
curUnit.data = localthis.getAvcDecryptedUnit(
decodedData,
decryptedBuffer
);
this.decryptBuffer(encryptedData.buffer).then(
(decryptedBuffer: ArrayBuffer) => {
curUnit.data = this.getAvcDecryptedUnit(decodedData, decryptedBuffer);
if (!sync) {
localthis.decryptAvcSamples(
samples,
sampleIndex,
unitIndex + 1,
callback
);
if (!this.decrypter.isSync()) {
this.decryptAvcSamples(samples, sampleIndex, unitIndex + 1, callback);
}
}
);
@ -197,18 +178,15 @@ class SampleAesDecrypter {
continue;
}
const sync = this.decrypter.isSync();
this.decryptAvcSample(
samples,
sampleIndex,
unitIndex,
callback,
curUnit,
sync
curUnit
);
if (!sync) {
if (!this.decrypter.isSync()) {
return;
}
}

View file

@ -1,4 +1,4 @@
import * as work from 'webworkify-webpack';
import work from './webworkify-webpack';
import { Events } from '../events';
import Transmuxer, {
TransmuxConfig,
@ -24,6 +24,7 @@ export default class TransmuxerInterface {
private observer: HlsEventEmitter;
private frag: Fragment | null = null;
private part: Part | null = null;
private useWorker: boolean;
private worker: any;
private onwmsg?: Function;
private transmuxer: Transmuxer | null = null;
@ -36,18 +37,18 @@ export default class TransmuxerInterface {
onTransmuxComplete: (transmuxResult: TransmuxerResult) => void,
onFlush: (chunkMeta: ChunkMetadata) => void
) {
const config = hls.config;
this.hls = hls;
this.id = id;
this.useWorker = !!config.enableWorker;
this.onTransmuxComplete = onTransmuxComplete;
this.onFlush = onFlush;
const config = hls.config;
const forwardMessage = (ev, data) => {
data = data || {};
data.frag = this.frag;
data.id = this.id;
hls.trigger(ev, data);
this.hls.trigger(ev, data);
};
// forward events to main thread
@ -63,7 +64,7 @@ export default class TransmuxerInterface {
// navigator.vendor is not always available in Web Worker
// refer to https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope/navigator
const vendor = navigator.vendor;
if (config.enableWorker && typeof Worker !== 'undefined') {
if (this.useWorker && typeof Worker !== 'undefined') {
logger.log('demuxing in webworker');
let worker;
try {
@ -73,10 +74,12 @@ export default class TransmuxerInterface {
this.onwmsg = this.onWorkerMessage.bind(this);
worker.addEventListener('message', this.onwmsg);
worker.onerror = (event) => {
hls.trigger(Events.ERROR, {
this.useWorker = false;
logger.warn('Exception in webworker, fallback to inline');
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.OTHER_ERROR,
details: ErrorDetails.INTERNAL_EXCEPTION,
fatal: true,
fatal: false,
event: 'demuxerWorker',
error: new Error(
`${event.message} (${event.filename}:${event.lineno})`
@ -159,6 +162,7 @@ export default class TransmuxerInterface {
chunkMeta.transmuxing.start = self.performance.now();
const { transmuxer, worker } = this;
const timeOffset = part ? part.start : frag.start;
// TODO: push "clear-lead" decrypt data for unencrypted fragments in streams with encrypted ones
const decryptdata = frag.decryptdata;
const lastFrag = this.frag;
@ -235,10 +239,20 @@ export default class TransmuxerInterface {
state
);
if (isPromise(transmuxResult)) {
transmuxResult.then((data) => {
this.handleTransmuxComplete(data);
});
transmuxer.async = true;
transmuxResult
.then((data) => {
this.handleTransmuxComplete(data);
})
.catch((error) => {
this.transmuxerError(
error,
chunkMeta,
'transmuxer-interface push error'
);
});
} else {
transmuxer.async = false;
this.handleTransmuxComplete(transmuxResult as TransmuxerResult);
}
}
@ -248,16 +262,29 @@ export default class TransmuxerInterface {
chunkMeta.transmuxing.start = self.performance.now();
const { transmuxer, worker } = this;
if (worker) {
1;
worker.postMessage({
cmd: 'flush',
chunkMeta,
});
} else if (transmuxer) {
const transmuxResult = transmuxer.flush(chunkMeta);
if (isPromise(transmuxResult)) {
transmuxResult.then((data) => {
this.handleFlushResult(data, chunkMeta);
});
let transmuxResult = transmuxer.flush(chunkMeta);
const asyncFlush = isPromise(transmuxResult);
if (asyncFlush || transmuxer.async) {
if (!isPromise(transmuxResult)) {
transmuxResult = Promise.resolve(transmuxResult);
}
transmuxResult
.then((data) => {
this.handleFlushResult(data, chunkMeta);
})
.catch((error) => {
this.transmuxerError(
error,
chunkMeta,
'transmuxer-interface flush error'
);
});
} else {
this.handleFlushResult(
transmuxResult as Array<TransmuxerResult>,
@ -267,6 +294,25 @@ export default class TransmuxerInterface {
}
}
private transmuxerError(
error: Error,
chunkMeta: ChunkMetadata,
reason: string
) {
if (!this.hls) {
return;
}
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
chunkMeta,
fatal: false,
error,
err: error,
reason,
});
}
private handleFlushResult(
results: Array<TransmuxerResult>,
chunkMeta: ChunkMetadata
@ -304,7 +350,6 @@ export default class TransmuxerInterface {
}
break;
/* falls through */
default: {
data.data = data.data || {};
data.data.frag = this.frag;

View file

@ -4,6 +4,7 @@ import { ILogFunction, enableLogs, logger } from '../utils/logger';
import { EventEmitter } from 'eventemitter3';
import type { RemuxedTrack, RemuxerResult } from '../types/remuxer';
import type { TransmuxerResult, ChunkMetadata } from '../types/transmuxer';
import { ErrorDetails, ErrorTypes } from '../errors';
export default function TransmuxerWorker(self) {
const observer = new EventEmitter();
@ -41,7 +42,7 @@ export default function TransmuxerWorker(self) {
data.vendor,
data.id
);
enableLogs(config.debug);
enableLogs(config.debug, data.id);
forwardWorkerLogs();
forwardMessage('init', null);
break;
@ -59,21 +60,51 @@ export default function TransmuxerWorker(self) {
data.state
);
if (isPromise(transmuxResult)) {
transmuxResult.then((data) => {
emitTransmuxComplete(self, data);
});
self.transmuxer.async = true;
transmuxResult
.then((data) => {
emitTransmuxComplete(self, data);
})
.catch((error) => {
forwardMessage(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
chunkMeta: data.chunkMeta,
fatal: false,
error,
err: error,
reason: `transmuxer-worker push error`,
});
});
} else {
self.transmuxer.async = false;
emitTransmuxComplete(self, transmuxResult);
}
break;
}
case 'flush': {
const id = data.chunkMeta;
const transmuxResult = self.transmuxer.flush(id);
if (isPromise(transmuxResult)) {
transmuxResult.then((results: Array<TransmuxerResult>) => {
handleFlushResult(self, results as Array<TransmuxerResult>, id);
});
let transmuxResult = self.transmuxer.flush(id);
const asyncFlush = isPromise(transmuxResult);
if (asyncFlush || self.transmuxer.async) {
if (!isPromise(transmuxResult)) {
transmuxResult = Promise.resolve(transmuxResult);
}
transmuxResult
.then((results: Array<TransmuxerResult>) => {
handleFlushResult(self, results as Array<TransmuxerResult>, id);
})
.catch((error) => {
forwardMessage(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
chunkMeta: data.chunkMeta,
fatal: false,
error,
err: error,
reason: `transmuxer-worker flush error`,
});
});
} else {
handleFlushResult(
self,

View file

@ -13,7 +13,7 @@ import type { Demuxer, DemuxerResult, KeyData } from '../types/demuxer';
import type { Remuxer } from '../types/remuxer';
import type { TransmuxerResult, ChunkMetadata } from '../types/transmuxer';
import type { HlsConfig } from '../config';
import type { LevelKey } from '../loader/level-key';
import type { DecryptData } from '../loader/level-key';
import type { PlaylistLevelType } from '../types/loader';
let now;
@ -26,19 +26,20 @@ try {
}
type MuxConfig =
| { demux: typeof TSDemuxer; remux: typeof MP4Remuxer }
| { demux: typeof MP4Demuxer; remux: typeof PassThroughRemuxer }
| { demux: typeof TSDemuxer; remux: typeof MP4Remuxer }
| { demux: typeof AACDemuxer; remux: typeof MP4Remuxer }
| { demux: typeof MP3Demuxer; remux: typeof MP4Remuxer };
const muxConfig: MuxConfig[] = [
{ demux: TSDemuxer, remux: MP4Remuxer },
{ demux: MP4Demuxer, remux: PassThroughRemuxer },
{ demux: TSDemuxer, remux: MP4Remuxer },
{ demux: AACDemuxer, remux: MP4Remuxer },
{ demux: MP3Demuxer, remux: MP4Remuxer },
];
export default class Transmuxer {
public async: boolean = false;
private observer: HlsEventEmitter;
private typeSupported: TypeSupported;
private config: HlsConfig;
@ -75,7 +76,7 @@ export default class Transmuxer {
push(
data: ArrayBuffer,
decryptdata: LevelKey | null,
decryptdata: DecryptData | null,
chunkMeta: ChunkMetadata,
state?: TransmuxState
): TransmuxerResult | Promise<TransmuxerResult> {
@ -83,7 +84,7 @@ export default class Transmuxer {
stats.executeStart = now();
let uintData: Uint8Array = new Uint8Array(data);
const { config, currentTransmuxState, transmuxConfig } = this;
const { currentTransmuxState, transmuxConfig } = this;
if (state) {
this.currentTransmuxState = state;
}
@ -104,31 +105,23 @@ export default class Transmuxer {
initSegmentData,
} = transmuxConfig;
// Reset muxers before probing to ensure that their state is clean, even if flushing occurs before a successful probe
if (discontinuity || trackSwitch || initSegmentChange) {
this.resetInitSegment(initSegmentData, audioCodec, videoCodec, duration);
}
if (discontinuity || initSegmentChange) {
this.resetInitialTimestamp(defaultInitPts);
}
if (!contiguous) {
this.resetContiguity();
}
const keyData = getEncryptionType(uintData, decryptdata);
if (keyData && keyData.method === 'AES-128') {
const decrypter = this.getDecrypter();
// Software decryption is synchronous; webCrypto is not
if (config.enableSoftwareAES) {
if (decrypter.isSync()) {
// Software decryption is progressive. Progressive decryption may not return a result on each call. Any cached
// data is handled in the flush() call
const decryptedData = decrypter.softwareDecrypt(
let decryptedData = decrypter.softwareDecrypt(
uintData,
keyData.key.buffer,
keyData.iv.buffer
);
// For Low-Latency HLS Parts, decrypt in place, since part parsing is expected on push progress
const loadingParts = chunkMeta.part > -1;
if (loadingParts) {
decryptedData = decrypter.flush();
}
if (!decryptedData) {
stats.executeEnd = now();
return emptyResult(chunkMeta);
@ -152,8 +145,27 @@ export default class Transmuxer {
}
}
if (this.needsProbing(uintData, discontinuity, trackSwitch)) {
this.configureTransmuxer(uintData, transmuxConfig);
const resetMuxers = this.needsProbing(discontinuity, trackSwitch);
if (resetMuxers) {
this.configureTransmuxer(uintData);
}
if (discontinuity || trackSwitch || initSegmentChange || resetMuxers) {
this.resetInitSegment(
initSegmentData,
audioCodec,
videoCodec,
duration,
decryptdata
);
}
if (discontinuity || initSegmentChange || resetMuxers) {
this.resetInitialTimestamp(defaultInitPts);
}
if (!contiguous) {
this.resetContiguity();
}
const result = this.transmux(
@ -283,7 +295,8 @@ export default class Transmuxer {
initSegmentData: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
trackDuration: number
trackDuration: number,
decryptdata: DecryptData | null
) {
const { demuxer, remuxer } = this;
if (!demuxer || !remuxer) {
@ -295,7 +308,12 @@ export default class Transmuxer {
videoCodec,
trackDuration
);
remuxer.resetInitSegment(initSegmentData, audioCodec, videoCodec);
remuxer.resetInitSegment(
initSegmentData,
audioCodec,
videoCodec,
decryptdata
);
}
destroy(): void {
@ -388,18 +406,8 @@ export default class Transmuxer {
});
}
private configureTransmuxer(
data: Uint8Array,
transmuxConfig: TransmuxConfig
) {
private configureTransmuxer(data: Uint8Array) {
const { config, observer, typeSupported, vendor } = this;
const {
audioCodec,
defaultInitPts,
duration,
initSegmentData,
videoCodec,
} = transmuxConfig;
// probe for content type
let mux;
for (let i = 0, len = muxConfig.length; i < len; i++) {
@ -427,16 +435,9 @@ export default class Transmuxer {
this.demuxer = new Demuxer(observer, config, typeSupported);
this.probe = Demuxer.probe;
}
// Ensure that muxers are always initialized with an initSegment
this.resetInitSegment(initSegmentData, audioCodec, videoCodec, duration);
this.resetInitialTimestamp(defaultInitPts);
}
private needsProbing(
data: Uint8Array,
discontinuity: boolean,
trackSwitch: boolean
): boolean {
private needsProbing(discontinuity: boolean, trackSwitch: boolean): boolean {
// in case of continuity change, or track switch
// we might switch from content type (AAC container to TS container, or TS to fmp4 for example)
return !this.demuxer || !this.remuxer || discontinuity || trackSwitch;
@ -445,7 +446,7 @@ export default class Transmuxer {
private getDecrypter(): Decrypter {
let decrypter = this.decrypter;
if (!decrypter) {
decrypter = this.decrypter = new Decrypter(this.observer, this.config);
decrypter = this.decrypter = new Decrypter(this.config);
}
return decrypter;
}
@ -453,7 +454,7 @@ export default class Transmuxer {
function getEncryptionType(
data: Uint8Array,
decryptData: LevelKey | null
decryptData: DecryptData | null
): KeyData | null {
let encryptionType: KeyData | null = null;
if (

View file

@ -56,6 +56,8 @@ export interface TypeSupported {
mp4: boolean;
}
const PACKET_LENGTH = 188;
class TSDemuxer implements Demuxer {
private readonly observer: HlsEventEmitter;
private readonly config: HlsConfig;
@ -87,8 +89,37 @@ class TSDemuxer implements Demuxer {
}
static probe(data: Uint8Array) {
// a TS init segment should contain at least 2 TS packets: PAT and PMT, each starting with 0x47
return data[0] === 0x47 && data[188] === 0x47;
const syncOffset = TSDemuxer.syncOffset(data);
if (syncOffset > 0) {
logger.warn(
`MPEG2-TS detected but first sync word found @ offset ${syncOffset}`
);
}
return syncOffset !== -1;
}
static syncOffset(data: Uint8Array): number {
const scanwindow =
Math.min(PACKET_LENGTH * 5, data.length - PACKET_LENGTH) + 1;
let i = 0;
while (i < scanwindow) {
// a TS init segment should contain at least 2 TS packets: PAT and PMT, each starting with 0x47
let foundPat = false;
for (let j = 0; j < scanwindow; j += PACKET_LENGTH) {
if (data[j] === 0x47) {
if (!foundPat && parsePID(data, j) === 0) {
foundPat = true;
}
if (foundPat && j + PACKET_LENGTH > scanwindow) {
return i;
}
} else {
break;
}
}
i++;
}
return -1;
}
/**
@ -161,6 +192,8 @@ class TSDemuxer implements Demuxer {
_id3Track.pesData = null;
}
this.aacOverFlow = null;
this.avcSample = null;
this.remainderData = null;
}
public demux(
@ -197,7 +230,7 @@ class TSDemuxer implements Demuxer {
this.remainderData = null;
}
if (len < 188 && !flush) {
if (len < PACKET_LENGTH && !flush) {
this.remainderData = data;
return {
audioTrack,
@ -207,7 +240,8 @@ class TSDemuxer implements Demuxer {
};
}
len -= len % 188;
const syncOffset = Math.max(0, TSDemuxer.syncOffset(data));
len -= (len - syncOffset) % PACKET_LENGTH;
if (len < data.byteLength && !flush) {
this.remainderData = new Uint8Array(
data.buffer,
@ -218,11 +252,10 @@ class TSDemuxer implements Demuxer {
// loop through TS packets
let tsPacketErrors = 0;
for (let start = 0; start < len; start += 188) {
for (let start = syncOffset; start < len; start += PACKET_LENGTH) {
if (data[start] === 0x47) {
const stt = !!(data[start + 1] & 0x40);
// pid is a 13-bit field starting at the last bit of TS[1]
const pid = ((data[start + 1] & 0x1f) << 8) + data[start + 2];
const pid = parsePID(data, start);
const atf = (data[start + 3] & 0x30) >> 4;
// if an adaption field is present, its length is specified by the fifth byte of the TS packet header.
@ -230,7 +263,7 @@ class TSDemuxer implements Demuxer {
if (atf > 1) {
offset = start + 5 + data[start + 4];
// continue if there is only adaptation field
if (offset === start + 188) {
if (offset === start + PACKET_LENGTH) {
continue;
}
} else {
@ -246,8 +279,8 @@ class TSDemuxer implements Demuxer {
avcData = { data: [], size: 0 };
}
if (avcData) {
avcData.data.push(data.subarray(offset, start + 188));
avcData.size += start + 188 - offset;
avcData.data.push(data.subarray(offset, start + PACKET_LENGTH));
avcData.size += start + PACKET_LENGTH - offset;
}
break;
case audioId:
@ -265,8 +298,8 @@ class TSDemuxer implements Demuxer {
audioData = { data: [], size: 0 };
}
if (audioData) {
audioData.data.push(data.subarray(offset, start + 188));
audioData.size += start + 188 - offset;
audioData.data.push(data.subarray(offset, start + PACKET_LENGTH));
audioData.size += start + PACKET_LENGTH - offset;
}
break;
case id3Id:
@ -278,8 +311,8 @@ class TSDemuxer implements Demuxer {
id3Data = { data: [], size: 0 };
}
if (id3Data) {
id3Data.data.push(data.subarray(offset, start + 188));
id3Data.size += start + 188 - offset;
id3Data.data.push(data.subarray(offset, start + PACKET_LENGTH));
id3Data.size += start + PACKET_LENGTH - offset;
}
break;
case 0:
@ -288,6 +321,7 @@ class TSDemuxer implements Demuxer {
}
pmtId = this._pmtId = parsePAT(data, offset);
// logger.log('PMT PID:' + this._pmtId);
break;
case pmtId: {
if (stt) {
@ -325,11 +359,13 @@ class TSDemuxer implements Demuxer {
if (unknownPID !== null && !pmtParsed) {
logger.log(`unknown PID '${unknownPID}' in TS found`);
unknownPID = null;
// we set it to -188, the += 188 in the for loop will reset start to 0
start = syncOffset - 188;
}
pmtParsed = this.pmtParsed = true;
break;
}
case 17:
case 0x11:
case 0x1fff:
break;
default:
@ -574,7 +610,8 @@ class TSDemuxer implements Demuxer {
avcSample.debug += 'SEI ';
}
parseSEIMessageFromNALu(
discardEPB(unit.data),
unit.data,
1,
pes.pts as number,
textTrack.samples
);
@ -938,6 +975,7 @@ class TSDemuxer implements Demuxer {
}
const id3Sample = Object.assign({}, pes as Required<PES>, {
type: this._avcTrack ? MetadataSchema.emsg : MetadataSchema.audioId3,
duration: Number.POSITIVE_INFINITY,
});
id3Track.samples.push(id3Sample);
}
@ -960,13 +998,22 @@ function createAVCSample(
};
}
function parsePAT(data, offset) {
// skip the PSI header and parse the first PMT entry
return ((data[offset + 10] & 0x1f) << 8) | data[offset + 11];
// logger.log('PMT PID:' + this._pmtId);
function parsePID(data: Uint8Array, offset: number): number {
// pid is a 13-bit field starting at the last bit of TS[1]
return ((data[offset + 1] & 0x1f) << 8) + data[offset + 2];
}
function parsePMT(data, offset, typeSupported, isSampleAes) {
function parsePAT(data: Uint8Array, offset: number): number {
// skip the PSI header and parse the first PMT entry
return ((data[offset + 10] & 0x1f) << 8) | data[offset + 11];
}
function parsePMT(
data: Uint8Array,
offset: number,
typeSupported: TypeSupported,
isSampleAes: boolean
) {
const result = { audio: -1, avc: -1, id3: -1, segmentCodec: 'aac' };
const sectionLength = ((data[offset + 1] & 0x0f) << 8) | data[offset + 2];
const tableEnd = offset + 3 + sectionLength - 4;
@ -977,7 +1024,7 @@ function parsePMT(data, offset, typeSupported, isSampleAes) {
// advance the offset to the first entry in the mapping table
offset += 12 + programInfoLength;
while (offset < tableEnd) {
const pid = ((data[offset + 1] & 0x1f) << 8) | data[offset + 2];
const pid = parsePID(data, offset);
switch (data[offset]) {
case 0xcf: // SAMPLE-AES AAC
if (!isSampleAes) {
@ -1173,45 +1220,4 @@ function pushAccessUnit(avcSample: ParsedAvcSample, avcTrack: DemuxedAvcTrack) {
}
}
/**
* remove Emulation Prevention bytes from a RBSP
*/
export function discardEPB(data: Uint8Array): Uint8Array {
const length = data.byteLength;
const EPBPositions = [] as Array<number>;
let i = 1;
// Find all `Emulation Prevention Bytes`
while (i < length - 2) {
if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 0x03) {
EPBPositions.push(i + 2);
i += 2;
} else {
i++;
}
}
// If no Emulation Prevention Bytes were found just return the original
// array
if (EPBPositions.length === 0) {
return data;
}
// Create a new array to hold the NAL unit data
const newLength = length - EPBPositions.length;
const newData = new Uint8Array(newLength);
let sourceIndex = 0;
for (i = 0; i < newLength; sourceIndex++, i++) {
if (sourceIndex === EPBPositions[0]) {
// Skip this byte
sourceIndex++;
// Remove this position index
EPBPositions.shift();
}
newData[i] = data[sourceIndex];
}
return newData;
}
export default TSDemuxer;

7
node_modules/hls.js/src/errors.ts generated vendored
View file

@ -19,8 +19,13 @@ export enum ErrorDetails {
KEY_SYSTEM_NO_KEYS = 'keySystemNoKeys',
KEY_SYSTEM_NO_ACCESS = 'keySystemNoAccess',
KEY_SYSTEM_NO_SESSION = 'keySystemNoSession',
KEY_SYSTEM_NO_CONFIGURED_LICENSE = 'keySystemNoConfiguredLicense',
KEY_SYSTEM_LICENSE_REQUEST_FAILED = 'keySystemLicenseRequestFailed',
KEY_SYSTEM_NO_INIT_DATA = 'keySystemNoInitData',
KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED = 'keySystemServerCertificateRequestFailed',
KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED = 'keySystemServerCertificateUpdateFailed',
KEY_SYSTEM_SESSION_UPDATE_FAILED = 'keySystemSessionUpdateFailed',
KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED = 'keySystemStatusOutputRestricted',
KEY_SYSTEM_STATUS_INTERNAL_ERROR = 'keySystemStatusInternalError',
// Identifier for a manifest load error - data: { url : faulty URL, response : { code: error code, text: error text }}
MANIFEST_LOAD_ERROR = 'manifestLoadError',
// Identifier for a manifest load timeout - data: { url : faulty URL, response : { code: error code, text: error text }}

2
node_modules/hls.js/src/events.ts generated vendored
View file

@ -160,7 +160,7 @@ export enum Events {
DESTROYING = 'hlsDestroying',
// fired when a decrypt key loading starts - data: { frag : fragment object }
KEY_LOADING = 'hlsKeyLoading',
// fired when a decrypt key loading is completed - data: { frag : fragment object, payload : key payload, stats : LoaderStats }
// fired when a decrypt key loading is completed - data: { frag : fragment object, keyInfo : KeyLoaderInfo }
KEY_LOADED = 'hlsKeyLoaded',
// deprecated; please use BACK_BUFFER_REACHED - data : { bufferEnd: number }
LIVE_BACK_BUFFER_REACHED = 'hlsLiveBackBufferReached',

96
node_modules/hls.js/src/hls.ts generated vendored
View file

@ -1,10 +1,10 @@
import * as URLToolkit from 'url-toolkit';
import PlaylistLoader from './loader/playlist-loader';
import KeyLoader from './loader/key-loader';
import ID3TrackController from './controller/id3-track-controller';
import LatencyController from './controller/latency-controller';
import LevelController from './controller/level-controller';
import { FragmentTracker } from './controller/fragment-tracker';
import KeyLoader from './loader/key-loader';
import StreamController from './controller/stream-controller';
import { isSupported } from './is-supported';
import { logger, enableLogs } from './utils/logger';
@ -23,8 +23,9 @@ import type SubtitleTrackController from './controller/subtitle-track-controller
import type { ComponentAPI, NetworkComponentAPI } from './types/component-api';
import type { MediaPlaylist } from './types/media-playlist';
import type { HlsConfig } from './config';
import type { Level } from './types/level';
import { HdcpLevel, HdcpLevels, Level } from './types/level';
import type { Fragment } from './loader/fragment';
import { BufferInfo } from './utils/buffer-helper';
/**
* @module Hls
@ -42,6 +43,7 @@ export default class Hls implements HlsEventEmitter {
private _emitter: HlsEventEmitter = new EventEmitter();
private _autoLevelCapping: number;
private _maxHdcpLevel: HdcpLevel = null;
private abrController: AbrController;
private bufferController: BufferController;
private capLevelController: CapLevelController;
@ -100,7 +102,7 @@ export default class Hls implements HlsEventEmitter {
constructor(userConfig: Partial<HlsConfig> = {}) {
const config = (this.config = mergeConfig(Hls.DefaultConfig, userConfig));
this.userConfig = userConfig;
enableLogs(config.debug);
enableLogs(config.debug, 'Hls instance');
this._autoLevelCapping = -1;
@ -123,16 +125,17 @@ export default class Hls implements HlsEventEmitter {
const fpsController = new ConfigFpsController(this);
const playListLoader = new PlaylistLoader(this);
const keyLoader = new KeyLoader(this);
const id3TrackController = new ID3TrackController(this);
// network controllers
const levelController = (this.levelController = new LevelController(this));
// FragmentTracker must be defined before StreamController because the order of event handling is important
const fragmentTracker = new FragmentTracker(this);
const keyLoader = new KeyLoader(this.config);
const streamController = (this.streamController = new StreamController(
this,
fragmentTracker
fragmentTracker,
keyLoader
));
// Cap level controller uses streamController to flush the buffer
@ -140,15 +143,14 @@ export default class Hls implements HlsEventEmitter {
// fpsController uses streamController to switch when frames are being dropped
fpsController.setStreamController(streamController);
const networkControllers = [
const networkControllers: NetworkComponentAPI[] = [
playListLoader,
keyLoader,
levelController,
streamController,
];
this.networkControllers = networkControllers;
const coreComponents = [
const coreComponents: ComponentAPI[] = [
abrController,
bufferController,
capLevelController,
@ -159,50 +161,45 @@ export default class Hls implements HlsEventEmitter {
this.audioTrackController = this.createController(
config.audioTrackController,
null,
networkControllers
);
this.createController(
config.audioStreamController,
fragmentTracker,
networkControllers
);
// subtitleTrackController must be defined before because the order of event handling is important
const AudioStreamControllerClass = config.audioStreamController;
if (AudioStreamControllerClass) {
networkControllers.push(
new AudioStreamControllerClass(this, fragmentTracker, keyLoader)
);
}
// subtitleTrackController must be defined before subtitleStreamController because the order of event handling is important
this.subtitleTrackController = this.createController(
config.subtitleTrackController,
null,
networkControllers
);
this.createController(
config.subtitleStreamController,
fragmentTracker,
networkControllers
);
this.createController(config.timelineController, null, coreComponents);
this.emeController = this.createController(
const SubtitleStreamControllerClass = config.subtitleStreamController;
if (SubtitleStreamControllerClass) {
networkControllers.push(
new SubtitleStreamControllerClass(this, fragmentTracker, keyLoader)
);
}
this.createController(config.timelineController, coreComponents);
keyLoader.emeController = this.emeController = this.createController(
config.emeController,
null,
coreComponents
);
this.cmcdController = this.createController(
config.cmcdController,
null,
coreComponents
);
this.latencyController = this.createController(
LatencyController,
null,
coreComponents
);
this.coreComponents = coreComponents;
}
createController(ControllerClass, fragmentTracker, components) {
createController(ControllerClass, components) {
if (ControllerClass) {
const controllerInstance = fragmentTracker
? new ControllerClass(this, fragmentTracker)
: new ControllerClass(this);
const controllerInstance = new ControllerClass(this);
if (components) {
components.push(controllerInstance);
}
@ -596,6 +593,16 @@ export default class Hls implements HlsEventEmitter {
}
}
get maxHdcpLevel(): HdcpLevel {
return this._maxHdcpLevel;
}
set maxHdcpLevel(value: HdcpLevel) {
if (HdcpLevels.indexOf(value) > -1) {
this._maxHdcpLevel = value;
}
}
/**
* True when automatic level selection enabled
* @type {boolean}
@ -638,7 +645,7 @@ export default class Hls implements HlsEventEmitter {
* @type {number}
*/
get maxAutoLevel(): number {
const { levels, autoLevelCapping } = this;
const { levels, autoLevelCapping, maxHdcpLevel } = this;
let maxAutoLevel;
if (autoLevelCapping === -1 && levels && levels.length) {
@ -647,6 +654,15 @@ export default class Hls implements HlsEventEmitter {
maxAutoLevel = autoLevelCapping;
}
if (maxHdcpLevel) {
for (let i = maxAutoLevel; i--; ) {
const hdcpLevel = levels[i].attrs['HDCP-LEVEL'];
if (hdcpLevel && hdcpLevel <= maxHdcpLevel) {
return i;
}
}
}
return maxAutoLevel;
}
@ -682,6 +698,10 @@ export default class Hls implements HlsEventEmitter {
return this.streamController.currentProgramDateTime;
}
public get mainForwardBufferInfo(): BufferInfo | null {
return this.streamController.getMainFwdBufferInfo();
}
/**
* @type {AudioTrack[]}
*/
@ -867,7 +887,11 @@ export type {
TSDemuxerConfig,
} from './config';
export type { CuesInterface } from './utils/cues';
export type { MediaKeyFunc, KeySystems } from './utils/mediakeys-helper';
export type {
MediaKeyFunc,
KeySystems,
KeySystemFormats,
} from './utils/mediakeys-helper';
export type { DateRange } from './loader/date-range';
export type { LoadStats } from './loader/load-stats';
export type { LevelKey } from './loader/level-key';
@ -879,10 +903,12 @@ export type {
UserdataSample,
} from './types/demuxer';
export type {
LevelParsed,
LevelAttributes,
HlsUrlParameters,
HdcpLevel,
HdcpLevels,
HlsSkip,
HlsUrlParameters,
LevelAttributes,
LevelParsed,
} from './types/level';
export type {
PlaylistLevelType,

View file

@ -1,5 +1,5 @@
import { getMediaSource } from './utils/mediasource-helper';
import { ExtendedSourceBuffer } from './types/buffer';
import type { ExtendedSourceBuffer } from './types/buffer';
function getSourceBuffer(): typeof self.SourceBuffer {
return self.SourceBuffer || (self as any).WebKitSourceBuffer;

View file

@ -7,7 +7,7 @@ import {
} from '../types/loader';
import type { HlsConfig } from '../config';
import type { BaseSegment, Part } from './fragment';
import type { FragLoadedData } from '../types/events';
import type { FragLoadedData, PartsLoadedData } from '../types/events';
const MIN_CHUNK_SIZE = Math.pow(2, 17); // 128kb
@ -82,10 +82,15 @@ export default class FragmentLoader {
loader.load(loaderContext, loaderConfig, {
onSuccess: (response, stats, context, networkDetails) => {
this.resetLoader(frag, loader);
let payload = response.data as ArrayBuffer;
if (context.resetIV && frag.decryptdata) {
frag.decryptdata.iv = new Uint8Array(payload.slice(0, 16));
payload = payload.slice(16);
}
resolve({
frag,
part: null,
payload: response.data as ArrayBuffer,
payload,
networkDetails,
});
},
@ -286,8 +291,23 @@ function createLoaderContext(
const start = segment.byteRangeStartOffset;
const end = segment.byteRangeEndOffset;
if (Number.isFinite(start) && Number.isFinite(end)) {
loaderContext.rangeStart = start;
loaderContext.rangeEnd = end;
let byteRangeStart = start;
let byteRangeEnd = end;
if (frag.sn === 'initSegment' && frag.decryptdata?.method === 'AES-128') {
// MAP segment encrypted with method 'AES-128', when served with HTTP Range,
// has the unencrypted size specified in the range.
// Ref: https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-08#section-6.3.6
const fragmentLen = end - start;
if (fragmentLen % 16) {
byteRangeEnd = end + (16 - (fragmentLen % 16));
}
if (start !== 0) {
loaderContext.resetIV = true;
byteRangeStart = start - 16;
}
}
loaderContext.rangeStart = byteRangeStart;
loaderContext.rangeEnd = byteRangeEnd;
}
return loaderContext;
}
@ -315,4 +335,6 @@ export interface FragLoadFailResult {
networkDetails: any;
}
export type FragmentLoadProgressCallback = (result: FragLoadedData) => void;
export type FragmentLoadProgressCallback = (
result: FragLoadedData | PartsLoadedData
) => void;

View file

@ -1,13 +1,14 @@
import { buildAbsoluteURL } from 'url-toolkit';
import { logger } from '../utils/logger';
import { LevelKey } from './level-key';
import { LoadStats } from './load-stats';
import { AttrList } from '../utils/attr-list';
import type {
FragmentLoaderContext,
KeyLoaderContext,
Loader,
PlaylistLevelType,
} from '../types/loader';
import type { KeySystemFormats } from '../utils/mediakeys-helper';
export enum ElementaryStreamTypes {
AUDIO = 'audio',
@ -101,14 +102,16 @@ export class Fragment extends BaseSegment {
public duration: number = 0;
// sn notates the sequence number for a segment, and if set to a string can be 'initSegment'
public sn: number | 'initSegment' = 0;
// levelkey is the EXT-X-KEY that applies to this segment for decryption
// levelkeys are the EXT-X-KEY tags that apply to this segment for decryption
// core difference from the private field _decryptdata is the lack of the initialized IV
// _decryptdata will set the IV for this segment based on the segment number in the fragment
public levelkey?: LevelKey;
public levelkeys?: { [key: string]: LevelKey };
// A string representing the fragment type
public readonly type: PlaylistLevelType;
// A reference to the loader. Set while the fragment is loading, and removed afterwards. Used to abort fragment loading
public loader: Loader<FragmentLoaderContext> | null = null;
// A reference to the key loader. Set while the key is loading, and removed afterwards. Used to abort key loading
public keyLoader: Loader<KeyLoaderContext> | null = null;
// The level/track index to which the fragment belongs
public level: number = -1;
// The continuity counter of the fragment
@ -141,6 +144,8 @@ export class Fragment extends BaseSegment {
public title: string | null = null;
// The Media Initialization Section for this segment
public initSegment: Fragment | null = null;
// Fragment is the last fragment in the media playlist
public endList?: boolean;
constructor(type: PlaylistLevelType, baseurl: string) {
super(baseurl);
@ -148,36 +153,25 @@ export class Fragment extends BaseSegment {
}
get decryptdata(): LevelKey | null {
if (!this.levelkey && !this._decryptdata) {
const { levelkeys } = this;
if (!levelkeys && !this._decryptdata) {
return null;
}
if (!this._decryptdata && this.levelkey) {
let sn = this.sn;
if (typeof sn !== 'number') {
// We are fetching decryption data for a initialization segment
// If the segment was encrypted with AES-128
// It must have an IV defined. We cannot substitute the Segment Number in.
if (
this.levelkey &&
this.levelkey.method === 'AES-128' &&
!this.levelkey.iv
) {
logger.warn(
`missing IV for initialization segment with method="${this.levelkey.method}" - compliance issue`
);
if (!this._decryptdata && this.levelkeys && !this.levelkeys.NONE) {
const key = this.levelkeys.identity;
if (key) {
this._decryptdata = key.getDecryptData(this.sn);
} else {
const keyFormats = Object.keys(this.levelkeys);
if (keyFormats.length === 1) {
return (this._decryptdata = this.levelkeys[
keyFormats[0]
].getDecryptData(this.sn));
} else {
// Multiple keys. key-loader to call Fragment.setKeyFormat based on selected key-system.
}
/*
Be converted to a Number.
'initSegment' will become NaN.
NaN, which when converted through ToInt32() -> +0.
---
Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation.
*/
sn = 0;
}
this._decryptdata = this.setDecryptDataFromLevelKey(this.levelkey, sn);
}
return this._decryptdata;
@ -205,48 +199,31 @@ export class Fragment extends BaseSegment {
// At the m3u8-parser level we need to add support for manifest signalled keyformats
// when we want the fragment to start reporting that it is encrypted.
// Currently, keyFormat will only be set for identity keys
if (this.decryptdata?.keyFormat && this.decryptdata.uri) {
if (this._decryptdata?.encrypted) {
return true;
} else if (this.levelkeys) {
const keyFormats = Object.keys(this.levelkeys);
const len = keyFormats.length;
if (len > 1 || (len === 1 && this.levelkeys[keyFormats[0]].encrypted)) {
return true;
}
}
return false;
}
/**
* Utility method for parseLevelPlaylist to create an initialization vector for a given segment
* @param {number} segmentNumber - segment number to generate IV with
* @returns {Uint8Array}
*/
createInitializationVector(segmentNumber: number): Uint8Array {
const uint8View = new Uint8Array(16);
for (let i = 12; i < 16; i++) {
uint8View[i] = (segmentNumber >> (8 * (15 - i))) & 0xff;
setKeyFormat(keyFormat: KeySystemFormats) {
if (this.levelkeys) {
const key = this.levelkeys[keyFormat];
if (key && !this._decryptdata) {
this._decryptdata = key.getDecryptData(this.sn);
}
}
return uint8View;
}
/**
* Utility method for parseLevelPlaylist to get a fragment's decryption data from the currently parsed encryption key data
* @param levelkey - a playlist's encryption info
* @param segmentNumber - the fragment's segment number
* @returns {LevelKey} - an object to be applied as a fragment's decryptdata
*/
setDecryptDataFromLevelKey(
levelkey: LevelKey,
segmentNumber: number
): LevelKey {
let decryptdata = levelkey;
if (levelkey?.method === 'AES-128' && levelkey.uri && !levelkey.iv) {
decryptdata = LevelKey.fromURI(levelkey.uri);
decryptdata.method = levelkey.method;
decryptdata.iv = this.createInitializationVector(segmentNumber);
decryptdata.keyFormat = 'identity';
}
return decryptdata;
abortRequests(): void {
this.loader?.abort();
this.keyLoader?.abort();
}
setElementaryStreamInfo(

View file

@ -1,101 +1,235 @@
/*
* Decrypt key Loader
*/
import { Events } from '../events';
import { ErrorTypes, ErrorDetails } from '../errors';
import { logger } from '../utils/logger';
import type Hls from '../hls';
import { Fragment } from './fragment';
import {
LoaderStats,
LoaderResponse,
LoaderContext,
LoaderConfiguration,
LoaderCallbacks,
Loader,
FragmentLoaderContext,
KeyLoaderContext,
} from '../types/loader';
import type { NetworkComponentAPI } from '../types/component-api';
import type { KeyLoadingData } from '../types/events';
import { LoadError } from './fragment-loader';
import type { HlsConfig } from '../hls';
import type { Fragment } from '../loader/fragment';
import type { ComponentAPI } from '../types/component-api';
import type { KeyLoadedData } from '../types/events';
import type { LevelKey } from './level-key';
import type EMEController from '../controller/eme-controller';
import type { MediaKeySessionContext } from '../controller/eme-controller';
import type { KeySystemFormats } from '../utils/mediakeys-helper';
interface KeyLoaderContext extends LoaderContext {
frag: Fragment;
export interface KeyLoaderInfo {
decryptdata: LevelKey;
keyLoadPromise: Promise<KeyLoadedData> | null;
loader: Loader<KeyLoaderContext> | null;
mediaKeySessionContext: MediaKeySessionContext | null;
}
export default class KeyLoader implements ComponentAPI {
private readonly config: HlsConfig;
public keyUriToKeyInfo: { [keyuri: string]: KeyLoaderInfo } = {};
public emeController: EMEController | null = null;
export default class KeyLoader implements NetworkComponentAPI {
private hls: Hls;
public loaders = {};
public decryptkey: Uint8Array | null = null;
public decrypturl: string | null = null;
constructor(hls: Hls) {
this.hls = hls;
this.registerListeners();
constructor(config: HlsConfig) {
this.config = config;
}
public startLoad(startPosition: number): void {}
public stopLoad(): void {
this.destroyInternalLoaders();
abort() {
for (const uri in this.keyUriToKeyInfo) {
const loader = this.keyUriToKeyInfo[uri].loader;
if (loader) {
loader.abort();
}
}
}
private registerListeners() {
this.hls.on(Events.KEY_LOADING, this.onKeyLoading, this);
detach() {
for (const uri in this.keyUriToKeyInfo) {
const keyInfo = this.keyUriToKeyInfo[uri];
// Remove cached EME keys on detach
if (
keyInfo.mediaKeySessionContext ||
keyInfo.decryptdata.isCommonEncryption
) {
delete this.keyUriToKeyInfo[uri];
}
}
}
private unregisterListeners() {
this.hls.off(Events.KEY_LOADING, this.onKeyLoading);
}
private destroyInternalLoaders(): void {
for (const loaderName in this.loaders) {
const loader = this.loaders[loaderName];
destroy() {
this.detach();
for (const uri in this.keyUriToKeyInfo) {
const loader = this.keyUriToKeyInfo[uri].loader;
if (loader) {
loader.destroy();
}
}
this.loaders = {};
this.keyUriToKeyInfo = {};
}
destroy(): void {
this.unregisterListeners();
this.destroyInternalLoaders();
createKeyLoadError(
frag: Fragment,
details: ErrorDetails = ErrorDetails.KEY_LOAD_ERROR,
networkDetails?: any,
message?: string
): LoadError {
return new LoadError({
type: ErrorTypes.NETWORK_ERROR,
details,
fatal: false,
frag,
networkDetails,
});
}
onKeyLoading(event: Events.KEY_LOADING, data: KeyLoadingData) {
const { frag } = data;
const type = frag.type;
const loader = this.loaders[type];
if (!frag.decryptdata) {
logger.warn('Missing decryption data on fragment in onKeyLoading');
return;
loadClear(
loadingFrag: Fragment,
encryptedFragments: Fragment[]
): void | Promise<void> {
if (this.emeController && this.config.emeEnabled) {
// access key-system with nearest key on start (loaidng frag is unencrypted)
const { sn, cc } = loadingFrag;
for (let i = 0; i < encryptedFragments.length; i++) {
const frag = encryptedFragments[i];
if (cc <= frag.cc && (sn === 'initSegment' || sn < frag.sn)) {
this.emeController
.selectKeySystemFormat(frag)
.then((keySystemFormat) => {
frag.setKeyFormat(keySystemFormat);
});
break;
}
}
}
}
load(frag: Fragment): Promise<KeyLoadedData> {
if (!frag.decryptdata && frag.encrypted && this.emeController) {
// Multiple keys, but none selected, resolve in eme-controller
return this.emeController
.selectKeySystemFormat(frag)
.then((keySystemFormat) => {
return this.loadInternal(frag, keySystemFormat);
});
}
// Load the key if the uri is different from previous one, or if the decrypt key has not yet been retrieved
const uri = frag.decryptdata.uri;
if (uri !== this.decrypturl || this.decryptkey === null) {
const config = this.hls.config;
if (loader) {
logger.warn(`abort previous key loader for type:${type}`);
loader.abort();
}
if (!uri) {
logger.warn('key uri is falsy');
return;
}
const Loader = config.loader;
const fragLoader =
(frag.loader =
this.loaders[type] =
new Loader(config) as Loader<FragmentLoaderContext>);
this.decrypturl = uri;
this.decryptkey = null;
return this.loadInternal(frag);
}
loadInternal(
frag: Fragment,
keySystemFormat?: KeySystemFormats
): Promise<KeyLoadedData> {
if (keySystemFormat) {
frag.setKeyFormat(keySystemFormat);
}
const decryptdata = frag.decryptdata;
if (!decryptdata) {
const errorMessage = keySystemFormat
? `Expected frag.decryptdata to be defined after setting format ${keySystemFormat}`
: 'Missing decryption data on fragment in onKeyLoading';
return Promise.reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
null,
errorMessage
)
);
}
const uri = decryptdata.uri;
if (!uri) {
return Promise.reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
null,
`Invalid key URI: "${uri}"`
)
);
}
let keyInfo = this.keyUriToKeyInfo[uri];
if (keyInfo?.decryptdata.key) {
decryptdata.key = keyInfo.decryptdata.key;
return Promise.resolve({ frag, keyInfo });
}
// Return key load promise as long as it does not have a mediakey session with an unusable key status
if (keyInfo?.keyLoadPromise) {
switch (keyInfo.mediaKeySessionContext?.keyStatus) {
case undefined:
case 'status-pending':
case 'usable':
case 'usable-in-future':
return keyInfo.keyLoadPromise;
}
// If we have a key session and status and it is not pending or usable, continue
// This will go back to the eme-controller for expired keys to get a new keyLoadPromise
}
// Load the key or return the loading promise
keyInfo = this.keyUriToKeyInfo[uri] = {
decryptdata,
keyLoadPromise: null,
loader: null,
mediaKeySessionContext: null,
};
switch (decryptdata.method) {
case 'ISO-23001-7':
case 'SAMPLE-AES':
case 'SAMPLE-AES-CENC':
case 'SAMPLE-AES-CTR':
if (decryptdata.keyFormat === 'identity') {
// loadKeyHTTP handles http(s) and data URLs
return this.loadKeyHTTP(keyInfo, frag);
}
return this.loadKeyEME(keyInfo, frag);
case 'AES-128':
return this.loadKeyHTTP(keyInfo, frag);
default:
return Promise.reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
null,
`Key supplied with unsupported METHOD: "${decryptdata.method}"`
)
);
}
}
loadKeyEME(keyInfo: KeyLoaderInfo, frag: Fragment): Promise<KeyLoadedData> {
const keyLoadedData: KeyLoadedData = { frag, keyInfo };
if (this.emeController && this.config.emeEnabled) {
const keySessionContextPromise =
this.emeController.loadKey(keyLoadedData);
if (keySessionContextPromise) {
return (keyInfo.keyLoadPromise = keySessionContextPromise.then(
(keySessionContext) => {
keyInfo.mediaKeySessionContext = keySessionContext;
return keyLoadedData;
}
)).catch((error) => {
// Remove promise for license renewal or retry
keyInfo.keyLoadPromise = null;
throw error;
});
}
}
return Promise.resolve(keyLoadedData);
}
loadKeyHTTP(keyInfo: KeyLoaderInfo, frag: Fragment): Promise<KeyLoadedData> {
const config = this.config;
const Loader = config.loader;
const keyLoader = new Loader(config) as Loader<KeyLoaderContext>;
frag.keyLoader = keyInfo.loader = keyLoader;
return (keyInfo.keyLoadPromise = new Promise((resolve, reject) => {
const loaderContext: KeyLoaderContext = {
url: uri,
frag: frag,
keyInfo,
frag,
responseType: 'arraybuffer',
url: keyInfo.decryptdata.uri,
};
// maxRetry is 0 so that instead of retrying the same key on the same variant multiple times,
@ -110,69 +244,94 @@ export default class KeyLoader implements NetworkComponentAPI {
};
const loaderCallbacks: LoaderCallbacks<KeyLoaderContext> = {
onSuccess: this.loadsuccess.bind(this),
onError: this.loaderror.bind(this),
onTimeout: this.loadtimeout.bind(this),
onSuccess: (
response: LoaderResponse,
stats: LoaderStats,
context: KeyLoaderContext,
networkDetails: any
) => {
const { frag, keyInfo, url: uri } = context;
if (!frag.decryptdata || keyInfo !== this.keyUriToKeyInfo[uri]) {
return reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
networkDetails,
'after key load, decryptdata unset or changed'
)
);
}
keyInfo.decryptdata.key = frag.decryptdata.key = new Uint8Array(
response.data as ArrayBuffer
);
// detach fragment key loader on load success
frag.keyLoader = null;
keyInfo.loader = null;
resolve({ frag, keyInfo });
},
onError: (
error: { code: number; text: string },
context: KeyLoaderContext,
networkDetails: any
) => {
this.resetLoader(context);
reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
networkDetails
)
);
},
onTimeout: (
stats: LoaderStats,
context: KeyLoaderContext,
networkDetails: any
) => {
this.resetLoader(context);
reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_TIMEOUT,
networkDetails
)
);
},
onAbort: (
stats: LoaderStats,
context: KeyLoaderContext,
networkDetails: any
) => {
this.resetLoader(context);
reject(
this.createKeyLoadError(
frag,
ErrorDetails.INTERNAL_ABORTED,
networkDetails
)
);
},
};
fragLoader.load(loaderContext, loaderConfig, loaderCallbacks);
} else if (this.decryptkey) {
// Return the key if it's already been loaded
frag.decryptdata.key = this.decryptkey;
this.hls.trigger(Events.KEY_LOADED, { frag: frag });
}
keyLoader.load(loaderContext, loaderConfig, loaderCallbacks);
}));
}
loadsuccess(
response: LoaderResponse,
stats: LoaderStats,
context: KeyLoaderContext
) {
const frag = context.frag;
if (!frag.decryptdata) {
logger.error('after key load, decryptdata unset');
return;
private resetLoader(context: KeyLoaderContext) {
const { frag, keyInfo, url: uri } = context;
const loader = keyInfo.loader;
if (frag.keyLoader === loader) {
frag.keyLoader = null;
keyInfo.loader = null;
}
this.decryptkey = frag.decryptdata.key = new Uint8Array(
response.data as ArrayBuffer
);
// detach fragment loader on load success
frag.loader = null;
delete this.loaders[frag.type];
this.hls.trigger(Events.KEY_LOADED, { frag: frag });
}
loaderror(response: LoaderResponse, context: KeyLoaderContext) {
const frag = context.frag;
const loader = frag.loader;
delete this.keyUriToKeyInfo[uri];
if (loader) {
loader.abort();
loader.destroy();
}
delete this.loaders[frag.type];
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.KEY_LOAD_ERROR,
fatal: false,
frag,
response,
});
}
loadtimeout(stats: LoaderStats, context: KeyLoaderContext) {
const frag = context.frag;
const loader = frag.loader;
if (loader) {
loader.abort();
}
delete this.loaders[frag.type];
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.KEY_LOAD_TIMEOUT,
fatal: false,
frag,
});
}
}

View file

@ -22,7 +22,6 @@ export class LevelDetails {
public advanced: boolean = true;
public availabilityDelay?: number; // Manifest reload synchronization
public misses: number = 0;
public needSidxRanges: boolean = false;
public startCC: number = 0;
public startSN: number = 0;
public startTimeOffset: number | null = null;
@ -48,9 +47,11 @@ export class LevelDetails {
public driftEndTime: number = 0;
public driftStart: number = 0;
public driftEnd: number = 0;
public encryptedFragments: Fragment[];
constructor(baseUrl) {
this.fragments = [];
this.encryptedFragments = [];
this.dateRanges = {};
this.url = baseUrl;
}

View file

@ -1,33 +1,204 @@
import { buildAbsoluteURL } from 'url-toolkit';
import {
changeEndianness,
convertDataUriToArrayBytes,
} from '../utils/keysystem-util';
import { KeySystemFormats } from '../utils/mediakeys-helper';
import { mp4pssh } from '../utils/mp4-tools';
import { logger } from '../utils/logger';
import { base64Decode } from '../utils/numeric-encoding-utils';
export class LevelKey {
private _uri: string | null = null;
public method: string | null = null;
public keyFormat: string | null = null;
public keyFormatVersions: string | null = null;
public keyID: string | null = null;
public key: Uint8Array | null = null;
let keyUriToKeyIdMap: { [uri: string]: Uint8Array } = {};
export interface DecryptData {
uri: string;
method: string;
keyFormat: string;
keyFormatVersions: number[];
iv: Uint8Array | null;
key: Uint8Array | null;
keyId: Uint8Array | null;
pssh: Uint8Array | null;
encrypted: boolean;
isCommonEncryption: boolean;
}
export class LevelKey implements DecryptData {
public readonly uri: string;
public readonly method: string;
public readonly keyFormat: string;
public readonly keyFormatVersions: number[];
public readonly encrypted: boolean;
public readonly isCommonEncryption: boolean;
public iv: Uint8Array | null = null;
public key: Uint8Array | null = null;
public keyId: Uint8Array | null = null;
public pssh: Uint8Array | null = null;
static fromURL(baseUrl: string, relativeUrl: string): LevelKey {
return new LevelKey(baseUrl, relativeUrl);
static clearKeyUriToKeyIdMap() {
keyUriToKeyIdMap = {};
}
static fromURI(uri: string): LevelKey {
return new LevelKey(uri);
constructor(
method: string,
uri: string,
format: string,
formatversions: number[] = [1],
iv: Uint8Array | null = null
) {
this.method = method;
this.uri = uri;
this.keyFormat = format;
this.keyFormatVersions = formatversions;
this.iv = iv;
this.encrypted = method ? method !== 'NONE' : false;
this.isCommonEncryption = this.encrypted && method !== 'AES-128';
}
private constructor(absoluteOrBaseURI: string, relativeURL?: string) {
if (relativeURL) {
this._uri = buildAbsoluteURL(absoluteOrBaseURI, relativeURL, {
alwaysNormalize: true,
});
} else {
this._uri = absoluteOrBaseURI;
public isSupported(): boolean {
// If it's Segment encryption or No encryption, just select that key system
if (this.method) {
if (this.method === 'AES-128' || this.method === 'NONE') {
return true;
}
switch (this.keyFormat) {
case 'identity':
// Maintain support for clear SAMPLE-AES with MPEG-3 TS
return this.method === 'SAMPLE-AES';
case KeySystemFormats.FAIRPLAY:
case KeySystemFormats.WIDEVINE:
case KeySystemFormats.PLAYREADY:
case KeySystemFormats.CLEARKEY:
return (
[
'ISO-23001-7',
'SAMPLE-AES',
'SAMPLE-AES-CENC',
'SAMPLE-AES-CTR',
].indexOf(this.method) !== -1
);
}
}
return false;
}
get uri() {
return this._uri;
public getDecryptData(sn: number | 'initSegment'): LevelKey | null {
if (!this.encrypted || !this.uri) {
return null;
}
if (this.method === 'AES-128' && this.uri && !this.iv) {
if (typeof sn !== 'number') {
// We are fetching decryption data for a initialization segment
// If the segment was encrypted with AES-128
// It must have an IV defined. We cannot substitute the Segment Number in.
if (this.method === 'AES-128' && !this.iv) {
logger.warn(
`missing IV for initialization segment with method="${this.method}" - compliance issue`
);
}
// Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation.
sn = 0;
}
const iv = createInitializationVector(sn);
const decryptdata = new LevelKey(
this.method,
this.uri,
'identity',
this.keyFormatVersions,
iv
);
return decryptdata;
}
// Initialize keyId if possible
const keyBytes = convertDataUriToArrayBytes(this.uri);
if (keyBytes) {
switch (this.keyFormat) {
case KeySystemFormats.WIDEVINE:
this.pssh = keyBytes;
// In case of widevine keyID is embedded in PSSH box. Read Key ID.
if (keyBytes.length >= 22) {
this.keyId = keyBytes.subarray(
keyBytes.length - 22,
keyBytes.length - 6
);
}
break;
case KeySystemFormats.PLAYREADY: {
const PlayReadyKeySystemUUID = new Uint8Array([
0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6,
0x5b, 0xe0, 0x88, 0x5f, 0x95,
]);
this.pssh = mp4pssh(PlayReadyKeySystemUUID, null, keyBytes);
const keyBytesUtf16 = new Uint16Array(
keyBytes.buffer,
keyBytes.byteOffset,
keyBytes.byteLength / 2
);
const keyByteStr = String.fromCharCode.apply(
null,
Array.from(keyBytesUtf16)
);
// Parse Playready WRMHeader XML
const xmlKeyBytes = keyByteStr.substring(
keyByteStr.indexOf('<'),
keyByteStr.length
);
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlKeyBytes, 'text/xml');
const keyData = xmlDoc.getElementsByTagName('KID')[0];
if (keyData) {
const keyId = keyData.childNodes[0]
? keyData.childNodes[0].nodeValue
: keyData.getAttribute('VALUE');
if (keyId) {
const keyIdArray = base64Decode(keyId).subarray(0, 16);
// KID value in PRO is a base64-encoded little endian GUID interpretation of UUID
// KID value in tenc is a big endian UUID GUID interpretation of UUID
changeEndianness(keyIdArray);
this.keyId = keyIdArray;
}
}
break;
}
default: {
let keydata = keyBytes.subarray(0, 16);
if (keydata.length !== 16) {
const padded = new Uint8Array(16);
padded.set(keydata, 16 - keydata.length);
keydata = padded;
}
this.keyId = keydata;
break;
}
}
}
// Default behavior: assign a new keyId for each uri
if (!this.keyId || this.keyId.byteLength !== 16) {
let keyId = keyUriToKeyIdMap[this.uri];
if (!keyId) {
const val =
Object.keys(keyUriToKeyIdMap).length % Number.MAX_SAFE_INTEGER;
keyId = new Uint8Array(16);
const dv = new DataView(keyId.buffer, 12, 4); // Just set the last 4 bytes
dv.setUint32(0, val);
keyUriToKeyIdMap[this.uri] = keyId;
}
this.keyId = keyId;
}
return this;
}
}
function createInitializationVector(segmentNumber: number): Uint8Array {
const uint8View = new Uint8Array(16);
for (let i = 12; i < 16; i++) {
uint8View[i] = (segmentNumber >> (8 * (15 - i))) & 0xff;
}
return uint8View;
}

View file

@ -1,10 +1,8 @@
import * as URLToolkit from 'url-toolkit';
import { buildAbsoluteURL } from 'url-toolkit';
import { DateRange } from './date-range';
import { Fragment, Part } from './fragment';
import { LevelDetails } from './level-details';
import { LevelKey } from './level-key';
import { AttrList } from '../utils/attr-list';
import { logger } from '../utils/logger';
import type { CodecType } from '../utils/codecs';
@ -19,9 +17,15 @@ import type { LevelAttributes, LevelParsed } from '../types/level';
type M3U8ParserFragments = Array<Fragment | null>;
type ParsedMultiVariantPlaylist = {
levels: LevelParsed[];
sessionData: Record<string, AttrList> | null;
sessionKeys: LevelKey[] | null;
};
// https://regex101.com is your friend
const MASTER_PLAYLIST_REGEX =
/#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-SESSION-DATA:([^\r\n]*)[\r\n]+/g;
/#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-SESSION-DATA:([^\r\n]*)[\r\n]+|#EXT-X-SESSION-KEY:([^\n\r]*)[\r\n]+/g;
const MASTER_PLAYLIST_MEDIA_REGEX = /#EXT-X-MEDIA:(.*)/g;
const LEVEL_PLAYLIST_REGEX_FAST = new RegExp(
@ -48,12 +52,6 @@ const LEVEL_PLAYLIST_REGEX_SLOW = new RegExp(
].join('|')
);
const MP4_REGEX_SUFFIX = /\.(mp4|m4s|m4v|m4a)$/i;
function isMP4Url(url: string): boolean {
return MP4_REGEX_SUFFIX.test(URLToolkit.parseURL(url)?.path ?? '');
}
export default class M3U8Parser {
static findGroup(
groups: Array<AudioGroup>,
@ -80,12 +78,18 @@ export default class M3U8Parser {
}
static resolve(url, baseUrl) {
return URLToolkit.buildAbsoluteURL(baseUrl, url, { alwaysNormalize: true });
return buildAbsoluteURL(baseUrl, url, { alwaysNormalize: true });
}
static parseMasterPlaylist(string: string, baseurl: string) {
const levels: Array<LevelParsed> = [];
static parseMasterPlaylist(
string: string,
baseurl: string
): ParsedMultiVariantPlaylist {
const levels: LevelParsed[] = [];
const levelsWithKnownCodecs: LevelParsed[] = [];
const sessionData: Record<string, AttrList> = {};
const sessionKeys: LevelKey[] = [];
let hasSessionData = false;
MASTER_PLAYLIST_REGEX.lastIndex = 0;
@ -118,6 +122,10 @@ export default class M3U8Parser {
level.videoCodec = M3U8Parser.convertAVC1ToAVCOTI(level.videoCodec);
}
if (!level.unknownCodecs?.length) {
levelsWithKnownCodecs.push(level);
}
levels.push(level);
} else if (result[3]) {
// '#EXT-X-SESSION-DATA' is found, parse session data in group 3
@ -126,11 +134,28 @@ export default class M3U8Parser {
hasSessionData = true;
sessionData[sessionAttrs['DATA-ID']] = sessionAttrs;
}
} else if (result[4]) {
// '#EXT-X-SESSION-KEY' is found
const keyTag = result[4];
const sessionKey = parseKey(keyTag, baseurl);
if (sessionKey.encrypted && sessionKey.isSupported()) {
sessionKeys.push(sessionKey);
} else {
logger.warn(
`[Keys] Ignoring invalid EXT-X-SESSION-KEY tag: "${keyTag}"`
);
}
}
}
// Filter out levels with unknown codecs if it does not remove all levels
const stripUnknownCodecLevels =
levelsWithKnownCodecs.length > 0 &&
levelsWithKnownCodecs.length < levels.length;
return {
levels,
levels: stripUnknownCodecLevels ? levelsWithKnownCodecs : levels,
sessionData: hasSessionData ? sessionData : null,
sessionKeys: sessionKeys.length ? sessionKeys : null,
};
}
@ -197,7 +222,7 @@ export default class M3U8Parser {
let frag: Fragment = new Fragment(type, baseurl);
let result: RegExpExecArray | RegExpMatchArray | null;
let i: number;
let levelkey: LevelKey | undefined;
let levelkeys: { [key: string]: LevelKey } | undefined;
let firstPdtIndex = -1;
let createNextFrag = false;
@ -232,8 +257,8 @@ export default class M3U8Parser {
// url
if (Number.isFinite(frag.duration)) {
frag.start = totalduration;
if (levelkey) {
frag.levelkey = levelkey;
if (levelkeys) {
setFragLevelKeys(frag, levelkeys, level);
}
frag.sn = currentSN;
frag.level = id;
@ -355,66 +380,21 @@ export default class M3U8Parser {
discontinuityCounter = parseInt(value1);
break;
case 'KEY': {
// https://tools.ietf.org/html/rfc8216#section-4.3.2.4
const keyAttrs = new AttrList(value1);
const decryptmethod = keyAttrs.enumeratedString('METHOD');
const decrypturi = keyAttrs.URI;
const decryptiv = keyAttrs.hexadecimalInteger('IV');
const decryptkeyformatversions =
keyAttrs.enumeratedString('KEYFORMATVERSIONS');
const decryptkeyid = keyAttrs.enumeratedString('KEYID');
// From RFC: This attribute is OPTIONAL; its absence indicates an implicit value of "identity".
const decryptkeyformat =
keyAttrs.enumeratedString('KEYFORMAT') ?? 'identity';
const unsupportedKnownKeyformatsInManifest = [
'com.apple.streamingkeydelivery',
'com.microsoft.playready',
'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', // widevine (v2)
'com.widevine', // earlier widevine (v1)
];
if (
unsupportedKnownKeyformatsInManifest.indexOf(decryptkeyformat) >
-1
) {
logger.warn(
`Keyformat ${decryptkeyformat} is not supported from the manifest`
);
continue;
} else if (decryptkeyformat !== 'identity') {
// We are supposed to skip keys we don't understand.
// As we currently only officially support identity keys
// from the manifest we shouldn't save any other key.
continue;
}
// TODO: multiple keys can be defined on a fragment, and we need to support this
// for clients that support both playready and widevine
if (decryptmethod) {
// TODO: need to determine if the level key is actually a relative URL
// if it isn't, then we should instead construct the LevelKey using fromURI.
levelkey = LevelKey.fromURL(baseurl, decrypturi);
if (
decrypturi &&
['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf(
decryptmethod
) >= 0
) {
levelkey.method = decryptmethod;
levelkey.keyFormat = decryptkeyformat;
if (decryptkeyid) {
levelkey.keyID = decryptkeyid;
}
if (decryptkeyformatversions) {
levelkey.keyFormatVersions = decryptkeyformatversions;
}
// Initialization Vector (IV)
levelkey.iv = decryptiv;
const levelKey = parseKey(value1, baseurl);
if (levelKey.isSupported()) {
if (levelKey.method === 'NONE') {
levelkeys = undefined;
break;
}
if (!levelkeys) {
levelkeys = {};
}
if (levelkeys[levelKey.keyFormat]) {
levelkeys = Object.assign({}, levelkeys);
}
levelkeys[levelKey.keyFormat] = levelKey;
} else {
logger.warn(`[Keys] Ignoring invalid EXT-X-KEY tag: "${value1}"`);
}
break;
}
@ -435,7 +415,7 @@ export default class M3U8Parser {
// #EXTINF: 6.0
// #EXT-X-MAP:URI="init.mp4
const init = new Fragment(type, baseurl);
setInitSegment(init, mapAttrs, id, levelkey);
setInitSegment(init, mapAttrs, id, levelkeys);
currentInitSegment = init;
frag.initSegment = currentInitSegment;
if (
@ -446,7 +426,7 @@ export default class M3U8Parser {
}
} else {
// Initial segment tag is before segment duration tag
setInitSegment(frag, mapAttrs, id, levelkey);
setInitSegment(frag, mapAttrs, id, levelkeys);
currentInitSegment = frag;
createNextFrag = true;
}
@ -520,6 +500,9 @@ export default class M3U8Parser {
assignProgramDateTime(frag, prevFrag);
frag.cc = discontinuityCounter;
level.fragmentHint = frag;
if (levelkeys) {
setFragLevelKeys(frag, levelkeys, level);
}
}
const fragmentLength = fragments.length;
const firstFragment = fragments[0];
@ -529,28 +512,11 @@ export default class M3U8Parser {
level.averagetargetduration = totalduration / fragmentLength;
const lastSn = lastFragment.sn;
level.endSN = lastSn !== 'initSegment' ? lastSn : 0;
if (!level.live) {
lastFragment.endList = true;
}
if (firstFragment) {
level.startCC = firstFragment.cc;
if (!firstFragment.initSegment) {
// this is a bit lurky but HLS really has no other way to tell us
// if the fragments are TS or MP4, except if we download them :/
// but this is to be able to handle SIDX.
if (
level.fragments.every(
(frag) => frag.relurl && isMP4Url(frag.relurl)
)
) {
logger.warn(
'MP4 fragments found but no init segment (probably no MAP, incomplete M3U8), trying to fetch SIDX'
);
frag = new Fragment(type, baseurl);
frag.relurl = lastFragment.relurl;
frag.level = id;
frag.sn = 'initSegment';
firstFragment.initSegment = frag;
level.needSidxRanges = true;
}
}
}
} else {
level.endSN = 0;
@ -579,6 +545,39 @@ export default class M3U8Parser {
}
}
function parseKey(keyTag: string, baseurl: string): LevelKey {
// https://tools.ietf.org/html/rfc8216#section-4.3.2.4
const keyAttrs = new AttrList(keyTag);
const decryptmethod = keyAttrs.enumeratedString('METHOD') ?? '';
const decrypturi = keyAttrs.URI;
const decryptiv = keyAttrs.hexadecimalInteger('IV');
const decryptkeyformatversions =
keyAttrs.enumeratedString('KEYFORMATVERSIONS');
// From RFC: This attribute is OPTIONAL; its absence indicates an implicit value of "identity".
const decryptkeyformat = keyAttrs.enumeratedString('KEYFORMAT') ?? 'identity';
if (decrypturi && keyAttrs.IV && !decryptiv) {
logger.error(`Invalid IV: ${keyAttrs.IV}`);
}
// If decrypturi is a URI with a scheme, then baseurl will be ignored
// No uri is allowed when METHOD is NONE
const resolvedUri = decrypturi ? M3U8Parser.resolve(decrypturi, baseurl) : '';
const keyFormatVersions = (
decryptkeyformatversions ? decryptkeyformatversions : '1'
)
.split('/')
.map(Number)
.filter(Number.isFinite);
return new LevelKey(
decryptmethod,
resolvedUri,
decryptkeyformat,
keyFormatVersions,
decryptiv
);
}
function setCodecs(codecs: Array<string>, level: LevelParsed) {
['video', 'audio', 'text'].forEach((type: CodecType) => {
const filtered = codecs.filter((codec) => isCodecType(codec, type));
@ -640,7 +639,7 @@ function setInitSegment(
frag: Fragment,
mapAttrs: AttrList,
id: number,
levelkey: LevelKey | undefined
levelkeys: { [key: string]: LevelKey } | undefined
) {
frag.relurl = mapAttrs.URI;
if (mapAttrs.BYTERANGE) {
@ -648,8 +647,27 @@ function setInitSegment(
}
frag.level = id;
frag.sn = 'initSegment';
if (levelkey) {
frag.levelkey = levelkey;
if (levelkeys) {
frag.levelkeys = levelkeys;
}
frag.initSegment = null;
}
function setFragLevelKeys(
frag: Fragment,
levelkeys: { [key: string]: LevelKey },
level: LevelDetails
) {
frag.levelkeys = levelkeys;
const { encryptedFragments } = level;
if (
(!encryptedFragments.length ||
encryptedFragments[encryptedFragments.length - 1].levelkeys !==
levelkeys) &&
Object.keys(levelkeys).some(
(format) => levelkeys![format].isCommonEncryption
)
) {
encryptedFragments.push(frag);
}
}

View file

@ -12,7 +12,6 @@
import { Events } from '../events';
import { ErrorDetails, ErrorTypes } from '../errors';
import { logger } from '../utils/logger';
import { parseSegmentIndex, findBox } from '../utils/mp4-tools';
import M3U8Parser from './m3u8-parser';
import type { LevelParsed } from '../types/level';
import type {
@ -316,12 +315,6 @@ class PlaylistLoader implements NetworkComponentAPI {
context: PlaylistLoaderContext,
networkDetails: any = null
): void {
if (context.isSidxRequest) {
this.handleSidxRequest(response, context);
this.handlePlaylistLoaded(response, stats, context, networkDetails);
return;
}
this.resetInternalLoader(context.type);
const string = response.data as string;
@ -376,7 +369,10 @@ class PlaylistLoader implements NetworkComponentAPI {
const url = getResponseUrl(response, context);
const { levels, sessionData } = M3U8Parser.parseMasterPlaylist(string, url);
const { levels, sessionData, sessionKeys } = M3U8Parser.parseMasterPlaylist(
string,
url
);
if (!levels.length) {
this.handleManifestParsingError(
response,
@ -457,6 +453,7 @@ class PlaylistLoader implements NetworkComponentAPI {
stats,
networkDetails,
sessionData,
sessionKeys,
});
}
@ -513,74 +510,19 @@ class PlaylistLoader implements NetworkComponentAPI {
stats,
networkDetails,
sessionData: null,
sessionKeys: null,
});
}
// save parsing time
stats.parsing.end = performance.now();
// in case we need SIDX ranges
// return early after calling load for
// the SIDX box.
if (levelDetails.needSidxRanges) {
const sidxUrl = levelDetails.fragments[0].initSegment?.url as string;
this.load({
url: sidxUrl,
isSidxRequest: true,
type,
level,
levelDetails,
id,
groupId: null,
rangeStart: 0,
rangeEnd: 2048,
responseType: 'arraybuffer',
deliveryDirectives: null,
});
return;
}
// extend the context with the new levelDetails property
context.levelDetails = levelDetails;
this.handlePlaylistLoaded(response, stats, context, networkDetails);
}
private handleSidxRequest(
response: LoaderResponse,
context: PlaylistLoaderContext
): void {
const data = new Uint8Array(response.data as ArrayBuffer);
const sidxBox = findBox(data, ['sidx'])[0];
// if provided fragment does not contain sidx, early return
if (!sidxBox) {
return;
}
const sidxInfo = parseSegmentIndex(sidxBox);
if (!sidxInfo) {
return;
}
const sidxReferences = sidxInfo.references;
const levelDetails = context.levelDetails as LevelDetails;
sidxReferences.forEach((segmentRef, index) => {
const segRefInfo = segmentRef.info;
const frag = levelDetails.fragments[index];
if (frag.byteRange.length === 0) {
frag.setByteRange(
String(1 + segRefInfo.end - segRefInfo.start) +
'@' +
String(segRefInfo.start)
);
}
if (frag.initSegment) {
const moovBox = findBox(data, ['moov'])[0];
const moovEndOffset = moovBox ? moovBox.length : null;
frag.initSegment.setByteRange(String(moovEndOffset) + '@0');
}
});
}
private handleManifestParsingError(
response: LoaderResponse,
context: PlaylistLoaderContext,

View file

@ -452,19 +452,21 @@ export default class MP4Remuxer implements Remuxer {
)} ms (${delta}dts) overlapping between fragments detected`
);
}
firstDTS = nextAvcDts;
const firstPTS = inputSamples[0].pts - delta;
inputSamples[0].dts = firstDTS;
inputSamples[0].pts = firstPTS;
logger.log(
`Video: First PTS/DTS adjusted: ${toMsFromMpegTsClock(
firstPTS,
true
)}/${toMsFromMpegTsClock(
firstDTS,
true
)}, delta: ${toMsFromMpegTsClock(delta, true)} ms`
);
if (!foundOverlap || nextAvcDts > inputSamples[0].pts) {
firstDTS = nextAvcDts;
const firstPTS = inputSamples[0].pts - delta;
inputSamples[0].dts = firstDTS;
inputSamples[0].pts = firstPTS;
logger.log(
`Video: First PTS/DTS adjusted: ${toMsFromMpegTsClock(
firstPTS,
true
)}/${toMsFromMpegTsClock(
firstDTS,
true
)}, delta: ${toMsFromMpegTsClock(delta, true)} ms`
);
}
}
}

View file

@ -2,7 +2,11 @@ import {
flushTextTrackMetadataCueSamples,
flushTextTrackUserdataCueSamples,
} from './mp4-remuxer';
import type { InitData, InitDataTrack } from '../utils/mp4-tools';
import {
InitData,
InitDataTrack,
patchEncyptionData,
} from '../utils/mp4-tools';
import {
getDuration,
getStartDTS,
@ -24,6 +28,7 @@ import type {
DemuxedUserdataTrack,
PassthroughTrack,
} from '../types/demuxer';
import type { DecryptData } from '../loader/level-key';
class PassThroughRemuxer implements Remuxer {
private emitInitSegment: boolean = false;
@ -48,11 +53,12 @@ class PassThroughRemuxer implements Remuxer {
public resetInitSegment(
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined
videoCodec: string | undefined,
decryptdata: DecryptData | null
) {
this.audioCodec = audioCodec;
this.videoCodec = videoCodec;
this.generateInitSegment(initSegment);
this.generateInitSegment(patchEncyptionData(initSegment, decryptdata));
this.emitInitSegment = true;
}

View file

@ -3,6 +3,7 @@ export type SourceBufferName = 'video' | 'audio' | 'audiovideo';
// eslint-disable-next-line no-restricted-globals
export type ExtendedSourceBuffer = SourceBuffer & {
ended?: boolean;
ending?: boolean;
changeType?: (type: string) => void;
};

View file

@ -96,6 +96,7 @@ export enum MetadataSchema {
export interface MetadataSample {
pts: number;
dts: number;
duration: number;
len?: number;
data: Uint8Array;
type: MetadataSchema;

View file

@ -21,6 +21,8 @@ import type { ErrorDetails, ErrorTypes } from '../errors';
import type { MetadataSample, UserdataSample } from './demuxer';
import type { AttrList } from '../utils/attr-list';
import type { HlsListeners } from '../events';
import { KeyLoaderInfo } from '../loader/key-loader';
import { LevelKey } from '../loader/level-key';
export interface MediaAttachingData {
media: HTMLMediaElement;
@ -82,6 +84,7 @@ export interface ManifestLoadedData {
levels: LevelParsed[];
networkDetails: any;
sessionData: Record<string, AttrList> | null;
sessionKeys: LevelKey[] | null;
stats: LoaderStats;
subtitles?: MediaPlaylist[];
url: string;
@ -91,6 +94,8 @@ export interface ManifestParsedData {
levels: Level[];
audioTracks: MediaPlaylist[];
subtitleTracks: MediaPlaylist[];
sessionData: Record<string, AttrList> | null;
sessionKeys: LevelKey[] | null;
firstLevel: number;
stats: LoaderStats;
audio: boolean;
@ -215,6 +220,7 @@ export interface ErrorData {
fatal: boolean;
buffer?: number;
bytes?: number;
chunkMeta?: ChunkMetadata;
context?: PlaylistLoaderContext;
error?: Error;
event?: keyof HlsListeners | 'demuxerWorker';
@ -338,6 +344,7 @@ export interface KeyLoadingData {
export interface KeyLoadedData {
frag: Fragment;
keyInfo: KeyLoaderInfo;
}
export interface BackBufferData {

View file

@ -18,6 +18,7 @@ export interface LevelParsed {
}
export interface LevelAttributes extends AttrList {
'ALLOWED-CPC'?: string;
AUDIO?: string;
AUTOSELECT?: string;
'AVERAGE-BANDWIDTH'?: string;
@ -29,15 +30,22 @@ export interface LevelAttributes extends AttrList {
DEFAULT?: string;
FORCED?: string;
'FRAME-RATE'?: string;
'HDCP-LEVEL'?: string;
LANGUAGE?: string;
NAME?: string;
'PATHWAY-ID'?: string;
'PROGRAM-ID'?: string;
RESOLUTION?: string;
SCORE?: string;
SUBTITLES?: string;
TYPE?: string;
URI?: string;
'VIDEO-RANGE'?: string;
}
export const HdcpLevels = ['NONE', 'TYPE-0', 'TYPE-1', 'TYPE-2', null] as const;
export type HdcpLevel = typeof HdcpLevels[number];
export enum HlsSkip {
No = '',
Yes = 'YES',
@ -78,7 +86,7 @@ export class HlsUrlParameters {
if (this.skip) {
url.searchParams.set('_HLS_skip', this.skip);
}
return url.toString();
return url.href;
}
}

View file

@ -1,5 +1,6 @@
import type { Fragment } from '../loader/fragment';
import type { Part } from '../loader/fragment';
import type { KeyLoaderInfo } from '../loader/key-loader';
import type { LevelDetails } from '../loader/level-details';
import type { HlsUrlParameters } from './level';
@ -21,6 +22,12 @@ export interface LoaderContext {
export interface FragmentLoaderContext extends LoaderContext {
frag: Fragment;
part: Part | null;
resetIV?: boolean;
}
export interface KeyLoaderContext extends LoaderContext {
keyInfo: KeyLoaderInfo;
frag: Fragment;
}
export interface LoaderConfiguration {
@ -159,8 +166,6 @@ export interface PlaylistLoaderContext extends LoaderContext {
id: number | null;
// track group id
groupId: string | null;
// defines if the loader is handling a sidx request for the playlist
isSidxRequest?: boolean;
// internal representation of a parsed m3u8 level playlist
levelDetails?: LevelDetails;
// Blocking playlist request delivery directives (or null id none were added to playlist url

View file

@ -9,6 +9,7 @@ import {
} from './demuxer';
import type { SourceBufferName } from './buffer';
import type { PlaylistLevelType } from './loader';
import type { DecryptData } from '../loader/level-key';
export interface Remuxer {
remux(
@ -24,7 +25,8 @@ export interface Remuxer {
resetInitSegment(
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined
videoCodec: string | undefined,
decryptdata: DecryptData | null
): void;
resetTimeStamp(defaultInitPTS): void;
resetNextTimestamp(): void;

View file

@ -221,9 +221,10 @@ class CaptionsLogger {
public time: number | null = null;
public verboseLevel: VerboseLevel = VerboseLevel.ERROR;
log(severity: VerboseLevel, msg: string): void {
log(severity: VerboseLevel, msg: string | (() => string)): void {
if (this.verboseLevel >= severity) {
logger.log(`${this.time} [${severity}] ${msg}`);
const m: string = typeof msg === 'function' ? msg() : msg;
logger.log(`${this.time} [${severity}] ${m}`);
}
}
}
@ -491,7 +492,8 @@ export class Row {
if (this.pos >= NR_COLS) {
this.logger.log(
VerboseLevel.ERROR,
'Cannot insert ' +
() =>
'Cannot insert ' +
byte.toString(16) +
' (' +
char +
@ -642,7 +644,10 @@ export class CaptionScreen {
}
setPAC(pacData: PACData) {
this.logger.log(VerboseLevel.INFO, 'pacData = ' + JSON.stringify(pacData));
this.logger.log(
VerboseLevel.INFO,
() => 'pacData = ' + JSON.stringify(pacData)
);
let newRow = pacData.row - 1;
if (this.nrRollUpRows && newRow < this.nrRollUpRows - 1) {
newRow = this.nrRollUpRows - 1;
@ -696,7 +701,10 @@ export class CaptionScreen {
* Set background/extra foreground, but first do back_space, and then insert space (backwards compatibility).
*/
setBkgData(bkgData: Partial<PenStyles>) {
this.logger.log(VerboseLevel.INFO, 'bkgData = ' + JSON.stringify(bkgData));
this.logger.log(
VerboseLevel.INFO,
() => 'bkgData = ' + JSON.stringify(bkgData)
);
this.backSpace();
this.setPen(bkgData);
this.insertChar(0x20); // Space
@ -714,7 +722,7 @@ export class CaptionScreen {
);
return; // Not properly setup
}
this.logger.log(VerboseLevel.TEXT, this.getDisplayText());
this.logger.log(VerboseLevel.TEXT, () => this.getDisplayText());
const topRowIndex = this.currRow + 1 - this.nrRollUpRows;
const topRow = this.rows.splice(topRowIndex, 1)[0];
topRow.clear();
@ -832,7 +840,7 @@ class Cea608Channel {
}
this.mode = newMode;
this.logger.log(VerboseLevel.INFO, 'MODE=' + newMode);
this.logger.log(VerboseLevel.INFO, () => 'MODE=' + newMode);
if (this.mode === 'MODE_POP-ON') {
this.writeScreen = this.nonDisplayedMemory;
} else {
@ -855,12 +863,12 @@ class Cea608Channel {
this.writeScreen === this.displayedMemory ? 'DISP' : 'NON_DISP';
this.logger.log(
VerboseLevel.INFO,
screen + ': ' + this.writeScreen.getDisplayText(true)
() => screen + ': ' + this.writeScreen.getDisplayText(true)
);
if (this.mode === 'MODE_PAINT-ON' || this.mode === 'MODE_ROLL-UP') {
this.logger.log(
VerboseLevel.TEXT,
'DISPLAYED: ' + this.displayedMemory.getDisplayText(true)
() => 'DISPLAYED: ' + this.displayedMemory.getDisplayText(true)
);
this.outputDataUpdate();
}
@ -962,7 +970,7 @@ class Cea608Channel {
this.writeScreen = this.nonDisplayedMemory;
this.logger.log(
VerboseLevel.TEXT,
'DISP: ' + this.displayedMemory.getDisplayText()
() => 'DISP: ' + this.displayedMemory.getDisplayText()
);
}
this.outputDataUpdate(true);

View file

@ -25,6 +25,7 @@ const sampleEntryCodesISO = {
mp4a: true,
'raw ': true,
Opus: true,
opus: true, // browsers expect this to be lowercase despite MP4RA says 'Opus'
samr: true,
sawb: true,
sawp: true,
@ -42,7 +43,9 @@ const sampleEntryCodesISO = {
avcp: true,
av01: true,
drac: true,
dva1: true,
dvav: true,
dvh1: true,
dvhe: true,
encv: true,
hev1: true,

View file

@ -1,10 +1,10 @@
import { logger } from './logger';
import { adjustSliding } from '../controller/level-helper';
import type { Fragment } from '../loader/fragment';
import type { LevelDetails } from '../loader/level-details';
import type { Level } from '../types/level';
import type { RequiredProperties } from '../types/general';
import { adjustSliding } from '../controller/level-helper';
export function findFirstFragWithCC(fragments: Fragment[], cc: number) {
let firstFrag: Fragment | null = null;
@ -39,7 +39,8 @@ export function shouldAlignOnDiscontinuities(
// Find the first frag in the previous level which matches the CC of the first frag of the new level
export function findDiscontinuousReferenceFrag(
prevDetails: LevelDetails,
curDetails: LevelDetails
curDetails: LevelDetails,
referenceIndex: number = 0
) {
const prevFrags = prevDetails.fragments;
const curFrags = curDetails.fragments;
@ -174,14 +175,6 @@ export function alignPDT(details: LevelDetails, lastDetails: LevelDetails) {
}
}
export function alignFragmentByPDTDelta(frag: Fragment, delta: number) {
const { programDateTime } = frag;
if (!programDateTime) return;
const start = (programDateTime - delta) / 1000;
frag.start = frag.startPTS = start;
frag.endPTS = start + frag.duration;
}
/**
* Ensures appropriate time-alignment between renditions based on PDT. Unlike `alignPDT`, which adjusts
* the timeline based on the delta between PDTs of the 0th fragment of two playlists/`LevelDetails`,
@ -199,30 +192,31 @@ export function alignMediaPlaylistByPDT(
details: LevelDetails,
refDetails: LevelDetails
) {
// This check protects the unsafe "!" usage below for null program date time access.
if (
!refDetails.fragments.length ||
!details.hasProgramDateTime ||
!refDetails.hasProgramDateTime
) {
if (!details.hasProgramDateTime || !refDetails.hasProgramDateTime) {
return;
}
const refPDT = refDetails.fragments[0].programDateTime!; // hasProgramDateTime check above makes this safe.
const refStart = refDetails.fragments[0].start;
// Use the delta between the reference details' presentation timeline's start time and its PDT
// to align the other rendition's timeline.
const delta = refPDT - refStart * 1000;
// Per spec: "If any Media Playlist in a Master Playlist contains an EXT-X-PROGRAM-DATE-TIME tag, then all
// Media Playlists in that Master Playlist MUST contain EXT-X-PROGRAM-DATE-TIME tags with consistent mappings
// of date and time to media timestamps."
// So we should be able to use each rendition's PDT as a reference time and use the delta to compute our relevant
// start and end times.
// NOTE: This code assumes each level/details timelines have already been made "internally consistent"
details.fragments.forEach((frag) => {
alignFragmentByPDTDelta(frag, delta);
});
if (details.fragmentHint) {
alignFragmentByPDTDelta(details.fragmentHint, delta);
const fragments = details.fragments;
const refFragments = refDetails.fragments;
if (!fragments.length || !refFragments.length) {
return;
}
details.alignedSliding = true;
// Calculate a delta to apply to all fragments according to the delta in PDT times and start times
// of a fragment in the reference details, and a fragment in the target details of the same discontinuity.
// If a fragment of the same discontinuity was not found use the middle fragment of both.
const middleFrag = Math.round(refFragments.length / 2) - 1;
const refFrag = refFragments[middleFrag];
const frag =
findFirstFragWithCC(fragments, refFrag.cc) ||
fragments[Math.round(fragments.length / 2) - 1];
const refPDT = refFrag.programDateTime;
const targetPDT = frag.programDateTime;
if (refPDT === null || targetPDT === null) {
return;
}
const delta = (targetPDT - refPDT) / 1000 - (frag.start - refFrag.start);
adjustSlidingStart(delta, details);
}

View file

@ -133,7 +133,10 @@ class FetchLoader implements Loader<LoaderContext> {
self.performance.now(),
stats.loading.first
);
stats.loaded = stats.total = responseData[LENGTH];
const total = responseData[LENGTH];
if (total) {
stats.loaded = stats.total = total;
}
const loaderResponse = {
url: response.url,

View file

@ -3,7 +3,7 @@
*/
const Hex = {
hexDump: function (array) {
hexDump: function (array: Uint8Array) {
let str = '';
for (let i = 0; i < array.length; i++) {
let h = array[i].toString(16);

View file

@ -104,11 +104,6 @@ function parseTTML(ttml: string, syncTime: number): Array<VTTCue> {
const region = regionElements[cueElement.getAttribute('region')];
const style = styleElements[cueElement.getAttribute('style')];
// TODO: Add regions to track and cue (origin and extend)
// These values are hard-coded (for now) to simulate region settings in the demo
cue.position = 10;
cue.size = 80;
// Apply styles to cue
const styles = getTtmlStyles(region, style, styleElements);
const { textAlign } = styles;

View file

@ -52,7 +52,7 @@ function exportLoggerFunctions(
});
}
export function enableLogs(debugConfig: boolean | ILogger): void {
export function enableLogs(debugConfig: boolean | ILogger, id: string): void {
// check that console is available
if (
(self.console && debugConfig === true) ||
@ -71,7 +71,7 @@ export function enableLogs(debugConfig: boolean | ILogger): void {
// Some browsers don't allow to use bind on console object anyway
// fallback to default if needed
try {
exportedLogger.log();
exportedLogger.log(`Debug logs enabled for "${id}"`);
} catch (e) {
exportedLogger = fakeLogger;
}

View file

@ -1,16 +1,98 @@
import type { DRMSystemOptions, EMEControllerConfig } from '../config';
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/requestMediaKeySystemAccess
*/
export enum KeySystems {
WIDEVINE = 'com.widevine.alpha',
CLEARKEY = 'org.w3.clearkey',
FAIRPLAY = 'com.apple.fps',
PLAYREADY = 'com.microsoft.playready',
WIDEVINE = 'com.widevine.alpha',
}
// Playlist #EXT-X-KEY KEYFORMAT values
export enum KeySystemFormats {
CLEARKEY = 'org.w3.clearkey',
FAIRPLAY = 'com.apple.streamingkeydelivery',
PLAYREADY = 'com.microsoft.playready',
WIDEVINE = 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed',
}
export function keySystemFormatToKeySystemDomain(
format: KeySystemFormats
): KeySystems | undefined {
switch (format) {
case KeySystemFormats.FAIRPLAY:
return KeySystems.FAIRPLAY;
case KeySystemFormats.PLAYREADY:
return KeySystems.PLAYREADY;
case KeySystemFormats.WIDEVINE:
return KeySystems.WIDEVINE;
case KeySystemFormats.CLEARKEY:
return KeySystems.CLEARKEY;
}
}
// System IDs for which we can extract a key ID from "encrypted" event PSSH
export enum KeySystemIds {
// CENC = '1077efecc0b24d02ace33c1e52e2fb4b'
// CLEARKEY = 'e2719d58a985b3c9781ab030af78d30e',
// FAIRPLAY = '94ce86fb07ff4f43adb893d2fa968ca2',
// PLAYREADY = '9a04f07998404286ab92e65be0885f95',
WIDEVINE = 'edef8ba979d64acea3c827dcd51d21ed',
}
export function keySystemIdToKeySystemDomain(
systemId: KeySystemIds
): KeySystems | undefined {
if (systemId === KeySystemIds.WIDEVINE) {
return KeySystems.WIDEVINE;
// } else if (systemId === KeySystemIds.PLAYREADY) {
// return KeySystems.PLAYREADY;
// } else if (systemId === KeySystemIds.CENC || systemId === KeySystemIds.CLEARKEY) {
// return KeySystems.CLEARKEY;
}
}
export function keySystemDomainToKeySystemFormat(
keySystem: KeySystems
): KeySystemFormats | undefined {
switch (keySystem) {
case KeySystems.FAIRPLAY:
return KeySystemFormats.FAIRPLAY;
case KeySystems.PLAYREADY:
return KeySystemFormats.PLAYREADY;
case KeySystems.WIDEVINE:
return KeySystemFormats.WIDEVINE;
case KeySystems.CLEARKEY:
return KeySystemFormats.CLEARKEY;
}
}
export function getKeySystemsForConfig(
config: EMEControllerConfig
): KeySystems[] {
const { drmSystems, widevineLicenseUrl } = config;
const keySystemsToAttempt: KeySystems[] = drmSystems
? [
KeySystems.FAIRPLAY,
KeySystems.WIDEVINE,
KeySystems.PLAYREADY,
KeySystems.CLEARKEY,
].filter((keySystem) => !!drmSystems[keySystem])
: [];
if (!keySystemsToAttempt[KeySystems.WIDEVINE] && widevineLicenseUrl) {
keySystemsToAttempt.push(KeySystems.WIDEVINE);
}
return keySystemsToAttempt;
}
export type MediaKeyFunc = (
keySystem: KeySystems,
supportedConfigurations: MediaKeySystemConfiguration[]
) => Promise<MediaKeySystemAccess>;
const requestMediaKeySystemAccess = (function (): MediaKeyFunc | null {
export const requestMediaKeySystemAccess = (function (): MediaKeyFunc | null {
if (
typeof self !== 'undefined' &&
self.navigator &&
@ -22,4 +104,63 @@ const requestMediaKeySystemAccess = (function (): MediaKeyFunc | null {
}
})();
export { requestMediaKeySystemAccess };
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemConfiguration
*/
export function getSupportedMediaKeySystemConfigurations(
keySystem: KeySystems,
audioCodecs: string[],
videoCodecs: string[],
drmSystemOptions: DRMSystemOptions
): MediaKeySystemConfiguration[] {
let initDataTypes: string[];
switch (keySystem) {
case KeySystems.FAIRPLAY:
initDataTypes = ['cenc', 'sinf'];
break;
case KeySystems.WIDEVINE:
case KeySystems.PLAYREADY:
initDataTypes = ['cenc'];
break;
case KeySystems.CLEARKEY:
initDataTypes = ['cenc', 'keyids'];
break;
default:
throw new Error(`Unknown key-system: ${keySystem}`);
}
return createMediaKeySystemConfigurations(
initDataTypes,
audioCodecs,
videoCodecs,
drmSystemOptions
);
}
function createMediaKeySystemConfigurations(
initDataTypes: string[],
audioCodecs: string[],
videoCodecs: string[],
drmSystemOptions: DRMSystemOptions
): MediaKeySystemConfiguration[] {
const baseConfig: MediaKeySystemConfiguration = {
initDataTypes: initDataTypes,
persistentState: drmSystemOptions.persistentState || 'not-allowed',
distinctiveIdentifier:
drmSystemOptions.distinctiveIdentifier || 'not-allowed',
sessionTypes: drmSystemOptions.sessionTypes || [
drmSystemOptions.sessionType || 'temporary',
],
audioCapabilities: audioCodecs.map((codec) => ({
contentType: `audio/mp4; codecs="${codec}"`,
robustness: drmSystemOptions.audioRobustness || '',
encryptionScheme: drmSystemOptions.audioEncryptionScheme || null,
})),
videoCapabilities: videoCodecs.map((codec) => ({
contentType: `video/mp4; codecs="${codec}"`,
robustness: drmSystemOptions.videoRobustness || '',
encryptionScheme: drmSystemOptions.videoEncryptionScheme || null,
})),
};
return [baseConfig];
}

View file

@ -1,7 +1,10 @@
import { sliceUint8 } from './typed-array';
import { ElementaryStreamTypes } from '../loader/fragment';
import { PassthroughTrack, UserdataSample } from '../types/demuxer';
import { sliceUint8 } from './typed-array';
import { utf8ArrayToStr } from '../demux/id3';
import { logger } from '../utils/logger';
import Hex from './hex';
import type { PassthroughTrack, UserdataSample } from '../types/demuxer';
import type { DecryptData } from '../loader/level-key';
const UINT32_MAX = Math.pow(2, 32) - 1;
const push = [].push;
@ -272,6 +275,65 @@ export function parseInitSegment(initSegment: Uint8Array): InitData {
return result;
}
export function patchEncyptionData(
initSegment: Uint8Array | undefined,
decryptdata: DecryptData | null
): Uint8Array | undefined {
if (!initSegment || !decryptdata) {
return initSegment;
}
const keyId = decryptdata.keyId;
if (keyId && decryptdata.isCommonEncryption) {
const traks = findBox(initSegment, ['moov', 'trak']);
traks.forEach((trak) => {
const stsd = findBox(trak, ['mdia', 'minf', 'stbl', 'stsd'])[0];
// skip the sample entry count
const sampleEntries = stsd.subarray(8);
let encBoxes = findBox(sampleEntries, ['enca']);
const isAudio = encBoxes.length > 0;
if (!isAudio) {
encBoxes = findBox(sampleEntries, ['encv']);
}
encBoxes.forEach((enc) => {
const encBoxChildren = isAudio ? enc.subarray(28) : enc.subarray(78);
const sinfBoxes = findBox(encBoxChildren, ['sinf']);
sinfBoxes.forEach((sinf) => {
const tenc = parseSinf(sinf);
if (tenc) {
// Look for default key id (keyID offset is always 8 within the tenc box):
const tencKeyId = tenc.subarray(8, 24);
if (!tencKeyId.some((b) => b !== 0)) {
logger.log(
`[eme] Patching keyId in 'enc${
isAudio ? 'a' : 'v'
}>sinf>>tenc' box: ${Hex.hexDump(tencKeyId)} -> ${Hex.hexDump(
keyId
)}`
);
tenc.set(keyId, 8);
}
}
});
});
});
}
return initSegment;
}
export function parseSinf(sinf: Uint8Array): Uint8Array | null {
const schm = findBox(sinf, ['schm'])[0];
if (schm) {
const scheme = bin2str(schm.subarray(4, 8));
if (scheme === 'cbcs' || scheme === 'cenc') {
return findBox(sinf, ['schi', 'tenc'])[0];
}
}
logger.error(`[eme] missing 'schm' box`);
return null;
}
/**
* Determine the base media decode start time, in seconds, for an MP4
* fragment. If multiple fragments are specified, the earliest time is
@ -681,14 +743,14 @@ export function parseSamples(
while (naluTotalSize < sampleSize) {
const naluSize = readUint32(videoData, sampleOffset);
sampleOffset += 4;
const naluType = videoData[sampleOffset] & 0x1f;
if (isSEIMessage(isHEVCFlavor, naluType)) {
if (isSEIMessage(isHEVCFlavor, videoData[sampleOffset])) {
const data = videoData.subarray(
sampleOffset,
sampleOffset + naluSize
);
parseSEIMessageFromNALu(
data,
isHEVCFlavor ? 2 : 1,
timeOffset + compositionOffset / timescale,
seiSamples
);
@ -723,19 +785,26 @@ function isHEVC(codec: string) {
);
}
function isSEIMessage(isHEVCFlavor: boolean, naluType: number) {
return isHEVCFlavor ? naluType === 39 || naluType === 40 : naluType === 6;
function isSEIMessage(isHEVCFlavor: boolean, naluHeader: number) {
if (isHEVCFlavor) {
const naluType = (naluHeader >> 1) & 0x3f;
return naluType === 39 || naluType === 40;
} else {
const naluType = naluHeader & 0x1f;
return naluType === 6;
}
}
export function parseSEIMessageFromNALu(
unescapedData: Uint8Array,
headerSize: number,
pts: number,
samples: UserdataSample[]
) {
const data = discardEPB(unescapedData);
let seiPtr = 0;
// skip frameType
seiPtr++;
// skip nal header
seiPtr += headerSize;
let payloadType = 0;
let payloadSize = 0;
let endOfCaptions = false;
@ -840,7 +909,7 @@ export function parseSEIMessageFromNALu(
/**
* remove Emulation Prevention bytes from a RBSP
*/
function discardEPB(data: Uint8Array): Uint8Array {
export function discardEPB(data: Uint8Array): Uint8Array {
const length = data.byteLength;
const EPBPositions = [] as Array<number>;
let i = 1;
@ -962,3 +1031,115 @@ export function parseEmsg(data: Uint8Array): IEmsgParsingData {
payload,
};
}
export function mp4Box(type: ArrayLike<number>, ...payload: Uint8Array[]) {
const len = payload.length;
let size = 8;
let i = len;
while (i--) {
size += payload[i].byteLength;
}
const result = new Uint8Array(size);
result[0] = (size >> 24) & 0xff;
result[1] = (size >> 16) & 0xff;
result[2] = (size >> 8) & 0xff;
result[3] = size & 0xff;
result.set(type, 4);
for (i = 0, size = 8; i < len; i++) {
result.set(payload[i], size);
size += payload[i].byteLength;
}
return result;
}
export function mp4pssh(
systemId: Uint8Array,
keyids: Array<Uint8Array> | null,
data: Uint8Array
) {
if (systemId.byteLength !== 16) {
throw new RangeError('Invalid system id');
}
let version;
let kids;
if (keyids) {
version = 1;
kids = new Uint8Array(keyids.length * 16);
for (let ix = 0; ix < keyids.length; ix++) {
const k = keyids[ix]; // uint8array
if (k.byteLength !== 16) {
throw new RangeError('Invalid key');
}
kids.set(k, ix * 16);
}
} else {
version = 0;
kids = new Uint8Array();
}
let kidCount;
if (version > 0) {
kidCount = new Uint8Array(4);
if (keyids!.length > 0) {
new DataView(kidCount.buffer).setUint32(0, keyids!.length, false);
}
} else {
kidCount = new Uint8Array();
}
const dataSize = new Uint8Array(4);
if (data && data.byteLength > 0) {
new DataView(dataSize.buffer).setUint32(0, data.byteLength, false);
}
return mp4Box(
[112, 115, 115, 104],
new Uint8Array([
version,
0x00,
0x00,
0x00, // Flags
]),
systemId, // 16 bytes
kidCount,
kids,
dataSize,
data || new Uint8Array()
);
}
export function parsePssh(initData: ArrayBuffer) {
if (!(initData instanceof ArrayBuffer) || initData.byteLength < 32) {
return null;
}
const result = {
version: 0,
systemId: '',
kids: null as null | Uint8Array[],
data: null as null | Uint8Array,
};
const view = new DataView(initData);
const boxSize = view.getUint32(0);
if (initData.byteLength !== boxSize && boxSize > 44) {
return null;
}
const type = view.getUint32(4);
if (type !== 0x70737368) {
return null;
}
result.version = view.getUint32(8) >>> 24;
if (result.version > 1) {
return null;
}
result.systemId = Hex.hexDump(new Uint8Array(initData, 12, 16));
const dataSizeOrKidCount = view.getUint32(28);
if (result.version === 0) {
if (boxSize - 32 < dataSizeOrKidCount) {
return null;
}
result.data = new Uint8Array(initData, 32, dataSizeOrKidCount);
} else if (result.version === 1) {
result.kids = [];
for (let i = 0; i < dataSizeOrKidCount; i++) {
result.kids.push(new Uint8Array(initData, 32 + i * 16, 16));
}
}
return result;
}

View file

@ -7,7 +7,7 @@ const TimeRanges = {
let log = '';
const len = r.length;
for (let i = 0; i < len; i++) {
log += '[' + r.start(i).toFixed(3) + ',' + r.end(i).toFixed(3) + ']';
log += `[${r.start(i).toFixed(3)}-${r.end(i).toFixed(3)}]`;
}
return log;

View file

@ -163,14 +163,19 @@ class XhrLoader implements Loader<LoaderContext> {
xhr.onprogress = null;
const status = xhr.status;
// http status between 200 to 299 are all successful
if (status >= 200 && status < 300) {
const isArrayBuffer = xhr.responseType === 'arraybuffer';
if (
status >= 200 &&
status < 300 &&
((isArrayBuffer && xhr.response) || xhr.responseText !== null)
) {
stats.loading.end = Math.max(
self.performance.now(),
stats.loading.first
);
let data;
let len: number;
if (context.responseType === 'arraybuffer') {
if (isArrayBuffer) {
data = xhr.response;
len = data.byteLength;
} else {