p2p-media-loader-hlsjs
Version:
P2P Media Loader hls.js integration
508 lines (507 loc) • 17 kB
JavaScript
import { Core, Core as Core$1, CoreRequestError, debug, generateStreamShortId } from "p2p-media-loader-core";
//#region src/utils.ts
function getSegmentRuntimeId(segmentRequestUrl, byteRange) {
if (!byteRange) return segmentRequestUrl;
return `${segmentRequestUrl}|${byteRange.start}-${byteRange.end}`;
}
function getByteRange(rangeStart, rangeEnd) {
if (rangeStart !== void 0 && rangeEnd !== void 0 && rangeStart <= rangeEnd) return {
start: rangeStart,
end: rangeEnd
};
}
//#endregion
//#region src/fragment-loader.ts
var DEFAULT_DOWNLOAD_LATENCY = 10;
var FragmentLoaderBase = class {
context;
config;
stats;
#callbacks;
#createDefaultLoader;
#defaultLoader;
#core;
#response;
#segmentId;
constructor(config, core) {
this.#core = core;
this.#createDefaultLoader = () => new config.loader(config);
this.stats = {
aborted: false,
chunkCount: 0,
loading: {
start: 0,
first: 0,
end: 0
},
buffering: {
start: 0,
first: 0,
end: 0
},
parsing: {
start: 0,
end: 0
},
total: 1,
loaded: 1,
bwEstimate: 0,
retry: 0
};
}
load(context, config, callbacks) {
this.context = context;
this.config = config;
this.#callbacks = callbacks;
const { stats } = this;
const { rangeStart: start, rangeEnd: end } = context;
const byteRange = getByteRange(start, end !== void 0 ? end - 1 : void 0);
this.#segmentId = getSegmentRuntimeId(context.url, byteRange);
const isSegmentDownloadableByP2PCore = this.#core.isSegmentLoadable(this.#segmentId);
if (!this.#core.hasSegment(this.#segmentId) || !isSegmentDownloadableByP2PCore) {
this.#defaultLoader = this.#createDefaultLoader();
this.#defaultLoader.stats = this.stats;
this.#defaultLoader.load(context, config, callbacks);
return;
}
const onSuccess = (response) => {
if (!this.#callbacks) return;
this.#response = response;
const loadedBytes = this.#response.data.byteLength;
stats.loading = getLoadingStat(this.#response.bandwidth, loadedBytes, performance.now());
stats.total = loadedBytes;
stats.loaded = loadedBytes;
const engineData = this.#response.data.slice(0);
if (this.#callbacks.onProgress) this.#callbacks.onProgress(this.stats, context, engineData, void 0);
this.#callbacks.onSuccess({
data: engineData,
url: context.url
}, this.stats, context, void 0);
};
const onError = (error) => {
if (error instanceof CoreRequestError && error.type === "aborted" && this.stats.aborted) return;
this.#handleError(error);
};
this.#core.loadSegment(this.#segmentId, {
onSuccess,
onError
});
}
#handleError(thrownError) {
const error = {
code: 0,
text: ""
};
if (thrownError instanceof CoreRequestError && thrownError.type === "failed") error.text = thrownError.message;
else if (thrownError instanceof Error) error.text = thrownError.message;
this.#callbacks?.onError(error, this.context, null, this.stats);
}
#abortInternal() {
if (!this.#response && this.#segmentId) {
this.stats.aborted = true;
this.#core.abortSegmentLoading(this.#segmentId);
}
}
abort() {
if (this.#defaultLoader) this.#defaultLoader.abort();
else {
this.#abortInternal();
this.#callbacks?.onAbort?.(this.stats, this.context, {});
}
}
destroy() {
if (this.#defaultLoader) this.#defaultLoader.destroy();
else {
if (!this.stats.aborted) this.#abortInternal();
this.#callbacks = null;
this.config = null;
}
}
};
function getLoadingStat(targetBitrate, loadedBytes, loadingEndTime) {
const timeForLoading = targetBitrate > 0 ? loadedBytes * 8e3 / targetBitrate : 0;
const first = Math.max(0, loadingEndTime - timeForLoading);
return {
start: Math.max(0, first - DEFAULT_DOWNLOAD_LATENCY),
first,
end: loadingEndTime
};
}
//#endregion
//#region src/playlist-loader.ts
var PlaylistLoaderBase = class {
#defaultLoader;
context;
stats;
constructor(config) {
this.#defaultLoader = new config.loader(config);
this.stats = this.#defaultLoader.stats;
this.context = this.#defaultLoader.context;
}
load(context, config, callbacks) {
this.#defaultLoader.load(context, config, callbacks);
}
abort() {
this.#defaultLoader.abort();
}
destroy() {
this.#defaultLoader.destroy();
}
};
//#endregion
//#region src/segment-manager.ts
var SegmentManager = class {
core;
constructor(core) {
this.core = core;
}
processMainManifest(data) {
const { levels, audioTracks } = data;
for (const level of levels) {
const { url, bitrate, maxBitrate, videoCodec, width, height } = level;
const b = maxBitrate ?? bitrate;
const isMissingMetadata = b === 0;
const frameRate = level.attrs["FRAME-RATE"];
const videoRange = level.attrs["VIDEO-RANGE"];
const index = generateStreamShortId({
bitrate: b,
codecs: isMissingMetadata ? void 0 : videoCodec,
width: isMissingMetadata ? void 0 : width,
height: isMissingMetadata ? void 0 : height,
frameRate: isMissingMetadata ? void 0 : frameRate,
videoRange: isMissingMetadata ? void 0 : videoRange
});
this.core.addStreamIfNoneExists({
runtimeId: Array.isArray(url) ? url[0] : url,
type: "main",
index
});
}
for (const track of audioTracks) {
const { url, audioCodec, lang, channels, name } = track;
const index = generateStreamShortId({
bitrate: 0,
codecs: audioCodec,
language: lang,
channels,
name
});
this.core.addStreamIfNoneExists({
runtimeId: Array.isArray(url) ? url[0] : url,
type: "secondary",
index
});
}
}
updatePlaylist(data) {
const { details: { url, fragments, live } } = data;
const playlist = this.core.getStream(url);
if (!playlist) return;
const segmentToRemoveIds = new Set(playlist.segments.keys());
const newSegments = [];
fragments.forEach((fragment, index) => {
const { url: responseUrl, byteRange: fragByteRange, sn, start: startTime, end: endTime } = fragment;
const [start, end] = fragByteRange;
const byteRange = getByteRange(start, end !== void 0 ? end - 1 : void 0);
const runtimeId = getSegmentRuntimeId(responseUrl, byteRange);
segmentToRemoveIds.delete(runtimeId);
if (playlist.segments.has(runtimeId)) return;
newSegments.push({
runtimeId,
url: responseUrl,
externalId: live ? sn : index,
byteRange,
startTime,
endTime
});
});
if (!newSegments.length && !segmentToRemoveIds.size) return;
this.core.updateStream(url, newSegments, segmentToRemoveIds.values());
}
};
//#endregion
//#region src/engine-static.ts
function injectMixin(HlsJsClass) {
return class HlsJsWithP2PClass extends HlsJsClass {
#p2pEngine;
get p2pEngine() {
return this.#p2pEngine;
}
constructor(...args) {
const { p2p, ...hlsJsConfig } = args[0] ?? {};
const p2pEngine = new HlsJsP2PEngine(p2p);
super({
...hlsJsConfig,
...p2pEngine.getConfigForHlsJs()
});
p2pEngine.bindHls(this);
this.#p2pEngine = p2pEngine;
p2p?.onHlsJsCreated?.(this);
}
};
}
//#endregion
//#region src/engine.ts
var MAX_LIVE_SYNC_DURATION = 120;
/**
* Represents a Peer-to-Peer (P2P) engine for HLS (HTTP Live Streaming) to enhance media streaming efficiency.
* This class integrates P2P technologies into Hls.js, enabling the distribution of media segments via a peer network
* alongside traditional HTTP fetching. This reduces server bandwidth costs and improves scalability by sharing the load
* across multiple clients.
*
* The engine manages core functionalities such as segment fetching, segment management, peer connection management,
* and event handling related to the P2P and HLS processes.
*
* @example
* // Creating an instance of HlsJsP2PEngine with custom configuration
* const hlsP2PEngine = new HlsJsP2PEngine({
* core: {
* highDemandTimeWindow: 30, // 30 seconds
* simultaneousHttpDownloads: 3,
* webRtcMaxMessageSize: 64 * 1024, // 64 KB
* p2pNotReceivingBytesTimeoutMs: 10000, // 10 seconds
* p2pInactiveLoaderDestroyTimeoutMs: 15000, // 15 seconds
* httpNotReceivingBytesTimeoutMs: 8000, // 8 seconds
* httpErrorRetries: 2,
* p2pErrorRetries: 2,
* announceTrackers: ["wss://personal.tracker.com"],
* rtcConfig: {
* iceServers: [{ urls: "stun:personal.stun.com" }]
* },
* swarmId: "example-swarm-id"
* }
* });
*
*/
var HlsJsP2PEngine = class {
core;
segmentManager;
hlsInstanceGetter;
currentHlsInstance;
debug = debug("p2pml-hlsjs:engine");
/**
* Enhances a given `Hls.js` class by injecting additional Peer-to-Peer (P2P) functionalities.
*
* @returns The enhanced `Hls.js` class with P2P functionalities.
*
* @example
* const HlsWithP2P = HlsJsP2PEngine.injectMixin(Hls);
*
* const hls = new HlsWithP2P({
* // Hls.js configuration
* startLevel: 0, // Example of Hls.js config parameter
* p2p: {
* core: {
* // P2P core configuration
* },
* onHlsJsCreated(hls) {
* // Do something with the Hls.js instance
* },
* },
* });
*/
static injectMixin(hls) {
return injectMixin(hls);
}
/**
* Constructs an instance of `HlsJsP2PEngine`.
* @param config An optional configuration for the P2P engine setup.
*/
constructor(config) {
this.core = new Core$1(config?.core);
this.segmentManager = new SegmentManager(this.core);
}
/**
* Adds an event listener for the specified event.
* @param eventName The name of the event to listen for.
* @param listener The callback function to be invoked when the event is triggered.
*
* @example
* // Listening for a segment being successfully loaded
* p2pEngine.addEventListener('onSegmentLoaded', (details) => {
* console.log('Segment Loaded:', details);
* });
*
* @example
* // Handling segment load errors
* p2pEngine.addEventListener('onSegmentError', (errorDetails) => {
* console.error('Error loading segment:', errorDetails);
* });
*
* @example
* // Tracking data downloaded from peers
* p2pEngine.addEventListener('onChunkDownloaded', (bytesLength, downloadSource, peerId) => {
* console.log(`Downloaded ${bytesLength} bytes from ${downloadSource} ${peerId ? 'from peer ' + peerId : 'from server'}`);
* });
*/
addEventListener(eventName, listener) {
this.core.addEventListener(eventName, listener);
}
/**
* Removes an event listener for the specified event.
* @param eventName The name of the event.
* @param listener The callback function that was previously added.
*/
removeEventListener(eventName, listener) {
this.core.removeEventListener(eventName, listener);
}
/**
* Provides the Hls.js P2P specific configuration for Hls.js loaders.
* @returns An object containing the fragment loader (`fLoader`) and playlist loader (`pLoader`).
*/
getConfigForHlsJs() {
return {
fLoader: this.createFragmentLoaderClass(),
pLoader: this.createPlaylistLoaderClass()
};
}
/**
* Retrieves the current configuration of the Hls.js P2P engine.
* @returns A readonly version of the `HlsJsP2PEngineConfig`.
*/
getConfig() {
return { core: this.core.getConfig() };
}
/**
* Applies dynamic configuration updates to the P2P engine.
* @param dynamicConfig The configuration changes to apply.
*
* @example
* // Assuming `hlsP2PEngine` is an instance of HlsJsP2PEngine
*
* const newDynamicConfig = {
* core: {
* // Increase the number of cached segments to 1000
* cachedSegmentsCount: 1000,
* // 50 minutes of segments will be preemptively downloaded via HTTP connections
* httpDownloadTimeWindow: 3000,
* // 100 minutes of segments will be preemptively downloaded via P2P connections
* p2pDownloadTimeWindow: 6000,
* }
* };
*
* hlsP2PEngine.applyDynamicConfig(newDynamicConfig);
*/
applyDynamicConfig(dynamicConfig) {
if (dynamicConfig.core) this.core.applyDynamicConfig(dynamicConfig.core);
}
/**
* Sets the HLS instance used for handling media.
* @param hls The HLS instance, or a function that returns an HLS instance.
*/
bindHls(hls) {
this.hlsInstanceGetter = typeof hls === "function" ? hls : () => hls;
}
initHlsEvents() {
const hlsInstance = this.hlsInstanceGetter?.();
if (this.currentHlsInstance === hlsInstance) return;
if (this.currentHlsInstance) this.destroy();
this.currentHlsInstance = hlsInstance;
this.updateHlsEventsHandlers("register");
this.updateMediaElementEventHandlers("register");
}
updateHlsEventsHandlers(type) {
const hls = this.currentHlsInstance;
if (!hls) return;
const method = type === "register" ? "on" : "off";
hls[method]("hlsManifestLoaded", this.handleManifestLoaded);
hls[method]("hlsLevelSwitching", this.handleLevelSwitching);
hls[method]("hlsLevelUpdated", this.handleLevelUpdated);
hls[method]("hlsAudioTrackLoaded", this.handleLevelUpdated);
hls[method]("hlsDestroying", this.destroy);
hls[method]("hlsMediaAttaching", this.destroyCore);
hls[method]("hlsManifestLoading", this.destroyCore);
hls[method]("hlsMediaDetached", this.handleMediaDetached);
hls[method]("hlsMediaAttached", this.handleMediaAttached);
}
updateMediaElementEventHandlers = (type) => {
const media = this.currentHlsInstance?.media;
if (!media) return;
const method = type === "register" ? "addEventListener" : "removeEventListener";
media[method]("timeupdate", this.handlePlaybackUpdate);
media[method]("seeking", this.handlePlaybackUpdate);
media[method]("ratechange", this.handlePlaybackUpdate);
};
handleManifestLoaded = (event, data) => {
const networkDetails = data.networkDetails;
if (networkDetails instanceof XMLHttpRequest) this.core.setManifestResponseUrl(networkDetails.responseURL);
else if (networkDetails instanceof Response) this.core.setManifestResponseUrl(networkDetails.url);
this.segmentManager.processMainManifest(data);
};
handleLevelSwitching = (event, data) => {
if (data.bitrate) this.core.setActiveLevelBitrate(data.bitrate);
};
handleLevelUpdated = (event, data) => {
if (this.currentHlsInstance && data.details.fragments[0].type === "main" && data.details.fragments.length > 4) {
if (data.details.live && !this.currentHlsInstance.userConfig.liveSyncDuration && !this.currentHlsInstance.userConfig.liveSyncDurationCount) this.updateLiveSyncDurationCount(data);
if (!this.currentHlsInstance.userConfig.maxBufferLength && !this.currentHlsInstance.userConfig.maxMaxBufferLength) this.updateMaxBufferLength(data.details.targetduration);
}
this.core.setIsLive(data.details.live);
this.segmentManager.updatePlaylist(data);
};
updateLiveSyncDurationCount(data) {
const fragmentDuration = data.details.targetduration;
const maxLiveSyncCount = Math.floor(MAX_LIVE_SYNC_DURATION / fragmentDuration);
const newLiveSyncDurationCount = Math.min(data.details.fragments.length - 1, maxLiveSyncCount);
if (this.currentHlsInstance && this.currentHlsInstance.config.liveSyncDurationCount !== newLiveSyncDurationCount) {
this.debug(`Setting liveSyncDurationCount to ${newLiveSyncDurationCount}`);
this.currentHlsInstance.config.liveSyncDurationCount = newLiveSyncDurationCount;
}
}
updateMaxBufferLength(fragmentDuration) {
if (!this.currentHlsInstance) return;
const config = this.core.getConfig();
const highDemandTimeWindow = Math.max(config.mainStream.highDemandTimeWindow, config.secondaryStream.highDemandTimeWindow);
const p2pOptimalBufferLength = Math.max(fragmentDuration * 2, highDemandTimeWindow);
if (this.currentHlsInstance.config.maxBufferLength > p2pOptimalBufferLength) {
this.debug(`Setting maxBufferLength to ${p2pOptimalBufferLength}`);
this.currentHlsInstance.config.maxBufferLength = p2pOptimalBufferLength;
}
if (this.currentHlsInstance.config.maxMaxBufferLength > p2pOptimalBufferLength) {
this.debug(`Setting maxMaxBufferLength to ${p2pOptimalBufferLength}`);
this.currentHlsInstance.config.maxMaxBufferLength = p2pOptimalBufferLength;
}
}
handleMediaAttached = () => {
this.updateMediaElementEventHandlers("register");
};
handleMediaDetached = () => {
this.updateMediaElementEventHandlers("unregister");
};
handlePlaybackUpdate = (event) => {
const media = event.target;
this.core.updatePlayback(media.currentTime, media.playbackRate);
};
destroyCore = () => this.core.destroy();
/** Cleans up and releases all resources, and unregisters all event handlers. */
destroy = () => {
this.destroyCore();
this.updateHlsEventsHandlers("unregister");
this.updateMediaElementEventHandlers("unregister");
this.currentHlsInstance = void 0;
};
createFragmentLoaderClass() {
const { core } = this;
const engine = this;
return class FragmentLoader extends FragmentLoaderBase {
constructor(config) {
super(config, core);
}
static getEngine() {
return engine;
}
};
}
createPlaylistLoaderClass() {
const engine = this;
return class PlaylistLoader extends PlaylistLoaderBase {
constructor(config) {
super(config);
engine.initHlsEvents();
}
};
}
};
//#endregion
export { Core, HlsJsP2PEngine };
//# sourceMappingURL=p2p-media-loader-hlsjs.es.js.map