mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-14 15:56:38 +03:00
658 lines
18 KiB
JavaScript
658 lines
18 KiB
JavaScript
/*! @license
|
|
* Shaka Player
|
|
* Copyright 2025 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);
|
|
});
|
|
|