Files
Álvaro Velad Galván 4f62ce8b24 build: Reduce bundle size by deduplicating license headers (#10182)
Ensure generated bundles contain a single license header instead of
repeating the same notice across bundled modules.

Normalize all copyright years to 2016 and remove
duplicate license headers from generated bundles.

This reduces the final bundle size while preserving license information
in the distributed artifacts.
2026-06-05 12:43:28 +02:00

658 lines
18 KiB
JavaScript

/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.queue.QueueManager');
goog.require('goog.asserts');
goog.require('shaka.Player');
goog.require('shaka.config.RepeatMode');
goog.require('shaka.log');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.Error');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.Timer');
goog.requireType('shaka.media.PreloadManager');
/**
* @implements {shaka.extern.IQueueManager}
* @implements {shaka.util.IDestroyable}
* @export
*/
shaka.queue.QueueManager = class extends shaka.util.FakeEventTarget {
/**
* @param {shaka.Player} player
*/
constructor(player) {
super();
/** @private {?shaka.Player} */
this.player_ = player;
/** @private {?shaka.extern.QueueConfiguration} */
this.config_ = null;
/** @private {!Array<shaka.extern.QueueItem>} */
this.items_ = [];
/** @private {number} */
this.currentItemIndex_ = -1;
/**
* @private {?{
* item: shaka.extern.QueueItem,
* preloadManager: ?shaka.media.PreloadManager,
* }}
*/
this.preloadNext_ = null;
/**
* @private {?{
* item: shaka.extern.QueueItem,
* preloadManager: ?shaka.media.PreloadManager,
* }}
*/
this.preloadPrev_ = null;
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
/** @private {?shaka.util.Timer} */
this.repeatTimer_ = null;
}
/**
* @override
* @export
*/
async destroy() {
await this.removeAllItems();
this.player_ = null;
if (this.eventManager_) {
this.eventManager_.release();
this.eventManager_ = null;
}
if (this.repeatTimer_) {
this.repeatTimer_.stop();
this.repeatTimer_ = null;
}
// FakeEventTarget implements IReleasable
super.release();
}
/**
* @override
* @export
*/
configure(config) {
this.config_ = config;
}
/**
* @override
* @export
*/
getConfiguration() {
return this.config_;
}
/**
* @override
* @export
*/
setCustomPlayer(player) {
this.player_ = player;
}
/**
* @override
* @export
*/
getCurrentItem() {
if (this.items_.length && this.currentItemIndex_ >= 0 &&
this.currentItemIndex_ < this.items_.length) {
return this.items_[this.currentItemIndex_];
}
return null;
}
/**
* @override
* @export
*/
getCurrentItemIndex() {
return this.currentItemIndex_;
}
/**
* @override
* @export
*/
getItems() {
return this.items_.slice();
}
/**
* @override
* @export
*/
insertItems(items) {
this.items_.push(...items);
this.dispatchEvent(new shaka.util.FakeEvent(
shaka.util.FakeEvent.EventName.ItemsInserted));
}
/**
* @override
* @export
*/
async removeAllItems() {
this.eventManager_.removeAll();
if (this.player_ && this.items_.length && this.currentItemIndex_ >= 0) {
try {
await this.player_.unload();
} catch (e) {
// Ignore errors during unload
}
}
const promises = [];
if (this.preloadPrev_?.preloadManager &&
!this.preloadPrev_.preloadManager.isDestroyed()) {
promises.push(this.preloadPrev_.preloadManager.destroy());
}
this.preloadPrev_ = null;
if (this.preloadNext_?.preloadManager &&
!this.preloadNext_.preloadManager.isDestroyed()) {
promises.push(this.preloadNext_.preloadManager.destroy());
}
this.preloadNext_ = null;
for (const item of this.items_) {
if (item.preloadManager && !item.preloadManager.isDestroyed()) {
promises.push(item.preloadManager.destroy());
}
}
if (promises.length) {
await Promise.all(promises);
}
this.items_ = [];
this.currentItemIndex_ = -1;
this.dispatchEvent(new shaka.util.FakeEvent(
shaka.util.FakeEvent.EventName.ItemsRemoved));
}
/**
* @override
* @export
*/
async playItem(itemIndex) {
goog.asserts.assert(this.player_, 'We should have player');
this.eventManager_.removeAll();
if (this.repeatTimer_) {
this.repeatTimer_.stop();
this.repeatTimer_ = null;
}
if (!this.items_.length || itemIndex < 0 ||
itemIndex >= this.items_.length) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.QUEUE_INDEX_OUT_OF_BOUNDS);
}
const currentItem = this.getCurrentItem();
const item = this.items_[itemIndex];
if (this.currentItemIndex_ !== itemIndex) {
this.currentItemIndex_ = itemIndex;
this.dispatchEvent(new shaka.util.FakeEvent(
shaka.util.FakeEvent.EventName.CurrentItemChanged));
}
const mediaElement = this.player_.getMediaElement();
this.setupPreloadNext_(mediaElement);
this.setupRepeatOnComplete_(mediaElement);
const assetUriOrPreloader = this.getAssetOrPreloader_(item);
await this.cleanupPreloadPrev_(item, currentItem);
if (item.config) {
this.player_.resetConfiguration();
this.player_.configure(item.config);
}
if (item.extraText?.length ||
item.extraThumbnail?.length ||
item.extraChapter?.length) {
this.eventManager_.listenOnce(this.player_, 'streaming', async () => {
await this.addExtraTracks_(item);
});
}
await this.player_.load(assetUriOrPreloader, item.startTime, item.mimeType);
this.preloadNext_ = null;
}
/**
* Sets up preloading of the next item if applicable
*
* @param {HTMLMediaElement} mediaElement
* @private
*/
setupPreloadNext_(mediaElement) {
if (!this.config_ || this.config_.preloadNextUrlWindow <= 0) {
return;
}
let preloadInProcess = false;
const listener = async () => {
if (this.preloadNext_ || this.items_.length <= 1 || preloadInProcess ||
this.player_.isDynamic() || !mediaElement.duration) {
return;
}
const timeToEnd = this.player_.seekRange().end - mediaElement.currentTime;
if (isNaN(timeToEnd) || timeToEnd > this.config_.preloadNextUrlWindow) {
return;
}
preloadInProcess = true;
let nextItem = null;
const repeatMode = this.config_.repeatMode;
const nextIndex = this.currentItemIndex_ + 1;
if (nextIndex < this.items_.length) {
nextItem = this.items_[nextIndex];
} else if (repeatMode === shaka.config.RepeatMode.ALL) {
nextItem = this.items_[0];
}
if (nextItem &&
(!nextItem.preloadManager || nextItem.preloadManager.isDestroyed())) {
try {
const preloadManager = await this.player_.preload(
nextItem.manifestUri, nextItem.startTime,
nextItem.mimeType, nextItem.config,
/* throwOnPreloadNotSupported= */ true);
this.preloadNext_ = {item: nextItem, preloadManager};
} catch (e) {
// Ignore errors during preload
this.preloadNext_ = {item: nextItem, preloadManager: null};
}
// Remove listener once next item is preloaded
this.eventManager_.unlisten(mediaElement, 'timeupdate', listener);
}
preloadInProcess = false;
};
this.eventManager_.listen(mediaElement, 'timeupdate', listener);
}
/**
* Handles repeating the current item when paused
*
* @param {HTMLMediaElement} mediaElement
* @private
*/
playCurrentItemAfterPause_(mediaElement) {
if (mediaElement.paused) {
mediaElement.currentTime = this.player_.seekRange().start;
mediaElement.play();
} else {
this.eventManager_.listenOnce(mediaElement, 'paused', () => {
mediaElement.currentTime = this.player_.seekRange().start;
mediaElement.play();
});
}
}
/**
* Sets up repeat behavior on playback completion
*
* @param {HTMLMediaElement} mediaElement
* @private
*/
setupRepeatOnComplete_(mediaElement) {
this.eventManager_.listen(this.player_, 'complete', () => {
const repeatMode = this.config_?.repeatMode;
if (repeatMode === shaka.config.RepeatMode.SINGLE) {
this.playCurrentItemAfterPause_(mediaElement);
return;
}
const nextIndex = this.currentItemIndex_ + 1;
let targetIndex = null;
if (nextIndex < this.items_.length) {
targetIndex = nextIndex;
} else if (repeatMode === shaka.config.RepeatMode.ALL) {
targetIndex = (this.items_.length > 1) ? 0 : this.currentItemIndex_;
}
if (targetIndex !== null) {
if (targetIndex === this.currentItemIndex_) {
this.playCurrentItemAfterPause_(mediaElement);
} else {
if (this.repeatTimer_) {
this.repeatTimer_.stop();
this.repeatTimer_ = null;
}
this.repeatTimer_ = new shaka.util.Timer(() => {
goog.asserts.assert(targetIndex != null,
'targetIndex should not be null');
this.playItem(targetIndex).catch(() => {});
}).tickAfter(0);
}
}
});
}
/**
* Determines which asset to use: preloadPrev_, preloadNext_ or manifestUri
*
* @param {!shaka.extern.QueueItem} item
* @return {string|shaka.media.PreloadManager}
* @private
*/
getAssetOrPreloader_(item) {
let asset = item.manifestUri;
if (item.preloadManager && !item.preloadManager.isDestroyed()) {
asset = item.preloadManager;
} else if (this.preloadNext_?.item === item &&
this.preloadNext_.preloadManager) {
asset = this.preloadNext_.preloadManager;
} else if (this.preloadPrev_?.item === item &&
this.preloadPrev_.preloadManager) {
asset = this.preloadPrev_.preloadManager;
}
return asset;
}
/**
* Cleans up preloadPrev_ if no longer needed and saves preload of the
* previous item
*
* @param {!shaka.extern.QueueItem} currentItem
* @param {?shaka.extern.QueueItem} previousItem
* @private
*/
async cleanupPreloadPrev_(currentItem, previousItem) {
const usingPrev = this.preloadPrev_?.item === currentItem;
if (this.preloadPrev_ && !usingPrev && this.preloadPrev_.preloadManager &&
!this.preloadPrev_.preloadManager.isDestroyed()) {
await this.preloadPrev_.preloadManager.destroy();
}
this.preloadPrev_ = null;
if (this.config_?.preloadPrevItem && previousItem &&
this.player_.getLoadMode() === shaka.Player.LoadMode.MEDIA_SOURCE) {
try {
const preloadManager = await this.player_.unloadAndSavePreload(
/* initializeMediaSource= */ false, /* keepAdManager= */ false,
/* savePosition= */ false, /* isSwitchingContent= */ true);
this.preloadPrev_ = {item: previousItem, preloadManager};
} catch (e) {
this.preloadPrev_ = {item: previousItem, preloadManager: null};
}
}
}
/**
* Adds extra tracks (text, thumbnails, chapters) in parallel
*
* @param {!shaka.extern.QueueItem} item
* @return {!Promise}
* @private
*/
async addExtraTracks_(item) {
const textPromises = item.extraText?.map(async (extraText) => {
if (extraText.mime) {
await this.player_.addTextTrackAsync(
extraText.uri, extraText.language,
extraText.kind, extraText.mime, extraText.codecs);
} else {
await this.player_.addTextTrackAsync(
extraText.uri, extraText.language, extraText.kind);
}
}) || [];
const thumbnailPromises = item.extraThumbnail?.map(async (thumb) => {
await this.player_.addThumbnailsTrack(thumb);
}) || [];
const chapterPromises = item.extraChapter?.map(async (chapter) => {
await this.player_.addChaptersTrack(
chapter.uri, chapter.language, chapter.mime);
}) || [];
await Promise.all([
...textPromises,
...thumbnailPromises,
...chapterPromises,
]);
}
/**
* @param {string} url
* @param {boolean=} playOnLoad
* @return {!Promise}
* @override
* @export
*/
async loadFromM3uPlaylist(url, playOnLoad = false) {
goog.asserts.assert(this.player_, 'We should have player');
const networkingEngine = this.player_.getNetworkingEngine();
goog.asserts.assert(
networkingEngine, 'We should have networking engine');
// Use manifest retry parameters — the playlist is a manifest-like
// resource and shares the same back-off / retry configuration.
const retryParams =
this.player_.getConfiguration().manifest.retryParameters;
const request = shaka.net.NetworkingEngine.makeRequest(
[url], retryParams);
// RequestType.PLAYLIST ensures the right request filters and
// credentials are applied for playlist-type resources.
const response = await networkingEngine.request(
shaka.net.NetworkingEngine.RequestType.PLAYLIST,
request).promise;
const text = shaka.util.StringUtils.fromUTF8(response.data);
const items = shaka.queue.QueueManager.parseM3uPlaylist_(text, url);
if (items.length === 0) {
shaka.log.warning(
'QueueManager: No playable entries found in the M3U playlist.',
url);
return;
}
this.insertItems(items);
if (playOnLoad) {
await this.playItem(0);
}
}
/**
* Parses raw M3U/M3U8 playlist text and returns an array of
* {@link shaka.extern.QueueItem} objects ready to be inserted into the
* queue.
*
* Only lines that follow an #EXTINF directive are treated as stream URLs;
* bare URL-only playlists (no #EXTM3U / #EXTINF) are also supported —
* every non-comment, non-empty line is treated as a stream URI with no
* metadata in that case.
*
* @param {string} text
* @param {string} baseUrl The URL of the playlist to resolve relative paths.
* @return {!Array<shaka.extern.QueueItem>}
* @private
*/
static parseM3uPlaylist_(text, baseUrl) {
const lines = text.split(/\r?\n/);
if (!lines[0]?.trim().startsWith('#EXTM3U')) {
shaka.log.warning(
'QueueManager: Playlist does not start with #EXTM3U; ' +
'attempting to parse anyway.');
}
/** @type {!Array<shaka.extern.QueueItem>} */
const items = [];
/**
* Used to avoid duplicated channels.
* We dedupe primarily by tvg-id.
* @type {!Set<string>}
*/
const seenChannelIds = new Set();
/** @type {?shaka.extern.QueueItemMetadata} */
let pendingMetadata = null;
for (const line of lines) {
const trimmed = line.trim();
// Skip blank lines and header.
if (!trimmed || trimmed === '#EXTM3U') {
continue;
}
// Parse metadata.
if (trimmed.startsWith('#EXTINF:')) {
pendingMetadata =
shaka.queue.QueueManager.parseExtInf_(trimmed);
continue;
}
// Ignore unsupported directives.
if (trimmed.startsWith('#')) {
continue;
}
// This is a stream URL.
const manifestUri = shaka.util.ManifestParserUtils.resolveUris(
[baseUrl], [trimmed])[0];
// Skip duplicated channels by tvg-id.
const tvgId = pendingMetadata?.['tvg-id'];
if (tvgId && seenChannelIds.has(tvgId)) {
shaka.log.debug('QueueManager: Skipping duplicated channel:',
tvgId, manifestUri);
continue;
}
/** @type {shaka.extern.QueueItem} */
const item = {
manifestUri,
preloadManager: null,
startTime: null,
mimeType: null,
config: null,
extraText: null,
extraThumbnail: null,
extraChapter: null,
metadata: pendingMetadata,
};
items.push(item);
if (tvgId) {
seenChannelIds.add(tvgId);
}
pendingMetadata = null;
}
return items;
}
/**
* Parses a single #EXTINF line and extracts its attributes together with
* the human-readable display name that follows the last comma.
*
* Expected format (newlines added here for readability only):
* #EXTINF:-1 tvg-id="ch1" tvg-name="Channel 1"
* tvg-logo="https://example.com/logo.png"
* tvg-language="English" tvg-country="US"
* group-title="News",Channel 1 Display Name
*
* All key="value" attributes are copied directly into the returned
* metadata object using their original attribute names. Unknown
* attributes are preserved automatically.
*
* The standard QueueItemMetadata fields are also populated:
* - title <- tvg-name or display title
* - poster <- tvg-logo
*
* Attribute parsing is intentionally lenient: malformed or unsupported
* attributes are ignored, and missing attributes are omitted rather than
* set to empty strings.
*
* @param {string} line A single #EXTINF line from the playlist.
* @return {!shaka.extern.QueueItemMetadata}
* @private
*/
static parseExtInf_(line) {
// The human-readable channel name follows the *last* comma in the line.
// Everything before that comma contains the duration and attributes.
const commaIndex = line.lastIndexOf(',');
const attrsPart =
commaIndex >= 0 ? line.substring(0, commaIndex) : line;
const displayTitle =
commaIndex >= 0 ? line.substring(commaIndex + 1).trim() : '';
const attrRegex = /([\w-]+)="([^"]*)"/g;
/** @type {!Object<string, string>} */
const attrs = {};
let match;
while ((match = attrRegex.exec(attrsPart)) !== null) {
attrs[match[1]] = match[2];
}
/** @type {!shaka.extern.QueueItemMetadata} */
const metadata = {
...attrs,
title: attrs['tvg-name'] || displayTitle || undefined,
poster: attrs['tvg-logo'] || undefined,
};
// Always preserve the raw display title even when tvg-name is present,
// so callers can distinguish between the two if needed.
if (displayTitle) {
metadata['displayTitle'] = displayTitle;
}
return metadata;
}
};
shaka.Player.setQueueManagerFactory((player) => {
return new shaka.queue.QueueManager(player);
});