diff --git a/build/webpack/modern/webpack.modern.dev.cjs b/build/webpack/modern/webpack.modern.dev.cjs
index 8ca796a65a..165789e547 100644
--- a/build/webpack/modern/webpack.modern.dev.cjs
+++ b/build/webpack/modern/webpack.modern.dev.cjs
@@ -16,7 +16,7 @@ const umdDevConfig = merge(umdConfig, {
open: ['samples/index.html'],
hot: true,
compress: true,
- port: 3000
+ port: 3001
}
});
diff --git a/samples/advanced/list-mpds.html b/samples/advanced/list-mpds.html
new file mode 100644
index 0000000000..865038a439
--- /dev/null
+++ b/samples/advanced/list-mpds.html
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
List MPDs
+
This sample shows the following use cases of the List MPDs implementation in DASH
+
+ Case 0: Single Linked Period - An MPD with a single Period that links to an Imported MPD.
+ Case 1: Multiple Linked Period - An MPD with multiple linked Periods, each pointing to a different Imported MPD.
+ Case 2: Linked Period + Regular Period - An MPD with one linked Period followed by a regular Period.
+ Case 3: Regular Period + Linked Period - An MPD with one regular Period followed by a linked Period.
+ Case 4: EarliestResolutionTime > minBufferTime - An MPD where the earliestResolutionTime of the linked Period exceeds the min buffer time.
+ Case 5: EarliestResolutionTime < minBufferTime - An MPD where the earliestResolutionTime of the linked Period is less than the buffer time.
+ Case 6: Different Linked Period and Imported Period durations - An MPD where a Linked Period references an Imported MPD with a duration that does not match the Linked Period's duration in the main manifest
+ Case 7: First List MPD Period with start time =/ 0 - The MPD is of type list, and the first Period in the manifest has a start time that is not equal to PT0S (0 seconds).
+ Case 8: Retry - The MPD is of type list, and the ImportedMPD should not be downloadable
+
+
+
+
+
+
+
+
+
+
+ Select Case
+
+
+
+
+
+
Case 0: Single Linked Period
+
+
+
Expected behavior:
+
The player should download the Imported MPD and reproduce the unique Period.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/alternative/alternative-media-presentations.html b/samples/alternative/alternative-media-presentations.html
new file mode 100644
index 0000000000..aafd1bdb56
--- /dev/null
+++ b/samples/alternative/alternative-media-presentations.html
@@ -0,0 +1,433 @@
+
+
+
+
+
Alternative Media Presentations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Alternative Media Presentations
+
A sample showing alternative media presentations with a dedicated alternative video element. Configure replace or insert events with direct presentation times.
+
Additional Samples:
+
+
+
+
VOD Configuration
+
+ Main VOD URL:
+
+
+
+
+ Alternative VOD URL:
+
+
+
+
+
Event Type:
+
+ Replace
+ Insert
+
+
+ Replace: Replaces main content with alternative content.
+ Insert: Inserts alternative content, then returns to the same point in main content.
+
+
+
+
+
+
+
+
+
+
+
+
+ Start at Playhead
+
+
+
+
+
+ Add Event
+ Load Player
+
+
+
+
+
+
+
+
+
+
+
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/alternative/listen-mode.html b/samples/alternative/listen-mode.html
new file mode 100644
index 0000000000..9394489128
--- /dev/null
+++ b/samples/alternative/listen-mode.html
@@ -0,0 +1,372 @@
+
+
+
+
+
Listen Mode - Alternative Media Presentations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Listen Mode - Alternative Media Presentations
+
A sample demonstrating listen mode where an alternative content can be replaced without maxDuration, and then a status update can add maxDuration to return to original content.
+
+
+
Stream Configuration
+
+ Main Live Stream URL:
+
+
+
+
+ Alternative Live Stream URL:
+
+
+
+
+ Time Offset (seconds):
+
+ Time from now to trigger replace event
+
+
+
+ Load Player
+ End Alternative
+
+
+
+
+
+
+
+
+
+
+
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/alternative/live-to-live.html b/samples/alternative/live-to-live.html
new file mode 100644
index 0000000000..ee5026a1ec
--- /dev/null
+++ b/samples/alternative/live-to-live.html
@@ -0,0 +1,354 @@
+
+
+
+
+
Live-to-Live Alternative Media Presentations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Live-to-Live Alternative Media Presentations
+
A sample showing live-to-live alternative media presentations where both original and alternative content are live streams. Configure replace events with dynamic presentation times.
+
+
+
Live Stream Configuration
+
+ Main Live Stream URL:
+
+
+
+
+ Alternative Live Stream URL:
+
+
+
+
+
+
+
+
+
+
+
+
+ Start at Playhead
+
+
+
+
+
+ Add Replace Event
+ Load Player
+
+
+
+
+
+
+
+
+
+
+
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/alternative/sgai-insert-test.html b/samples/alternative/sgai-insert-test.html
new file mode 100644
index 0000000000..e74dc681f2
--- /dev/null
+++ b/samples/alternative/sgai-insert-test.html
@@ -0,0 +1,359 @@
+
+
+
+
+
SGAI Insert Mode Test - Alternative MPD
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SGAI Insert Mode Test
+
Testing SGAI with alternative MPD and list MPDs using insert mode only. The alternative content is inserted and then returns to the same point in the main content.
+
+
+
Configuration
+
+ Main VOD URL:
+
+ Main content URL
+
+
+
+ Alternative URL (List MPD):
+
+ Alternative content to insert (List MPD)
+
+
+
+ Insert Mode: Alternative content is inserted at the specified time, then playback returns to the same point in the main content.
+
+
+
+
+
+
+ Max Duration (ms):
+
+
+
+
+
+
+
+
+
+ Start at Playhead
+
+
+
+
+
+ Add Insert Event
+ Load Player
+
+
+
+
+
+
+
+
+
+
+
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/highlighter.js b/samples/highlighter.js
index a670dd2899..4f34ce4f9e 100644
--- a/samples/highlighter.js
+++ b/samples/highlighter.js
@@ -9,7 +9,7 @@ script.setAttribute('src', '//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0
head.append(style);
head.append(script);
-codeOutput.innerHTML += '
Source codeCopy to clipboard
';
+codeOutput.innerHTML += '
Source codeCopy to clipboard
';
/**
* This helper functions checks how many whitespaces preceed the last tag, which should have 0
diff --git a/samples/samples.json b/samples/samples.json
index 357d5e1552..b56b2bba3e 100644
--- a/samples/samples.json
+++ b/samples/samples.json
@@ -821,6 +821,18 @@
"Video",
"Audio"
]
+ },
+ {
+ "title": "List MPD",
+ "description": "This sample demonstrates the List MPD implementation in dash.js",
+ "href": "advanced/list-mpds.html",
+ "image": "lib/img/bbb-1.jpg",
+ "labels": [
+ "VoD",
+ "Video",
+ "Audio",
+ "List MPD"
+ ]
}
]
},
@@ -840,6 +852,64 @@
}
]
},
+ {
+ "section": "Alternative Media Presentations",
+ "samples": [
+ {
+ "title": "Alternative Media Presentations",
+ "description": "A sample showing alternative media presentations with a dedicated alternative video element for content switching (Insert/Replace modes).",
+ "href": "alternative/alternative-media-presentations.html",
+ "image": "lib/img/bbb-1.jpg",
+ "labels": [
+ "VoD",
+ "Alternative MPD",
+ "Video",
+ "Audio"
+ ]
+ },
+ {
+ "title": "Live-to-Live Alternative Media Presentations",
+ "description": "A sample showing live-to-live alternative media presentations where both original and alternative content are live streams. Configure replace events with dynamic presentation times.",
+ "href": "alternative/live-to-live.html",
+ "image": "lib/img/livesim-1.jpg",
+ "labels": [
+ "Live",
+ "Alternative MPD",
+ "Replace Events",
+ "Video",
+ "Audio"
+ ]
+ },
+ {
+ "title": "Alternative MPD Listen Mode",
+ "description": "A sample demonstrating listen mode where alternative content can be inserted without maxDuration, and then a status update can add maxDuration to return to original content. Simplified interface with Insert and Return buttons.",
+ "href": "alternative/listen-mode.html",
+ "image": "lib/img/livesim-1.jpg",
+ "labels": [
+ "Live",
+ "Alternative MPD",
+ "Insert Events",
+ "Status Update",
+ "Video",
+ "Audio"
+ ]
+ },
+ {
+ "title": "SGAI Insert Mode Test",
+ "description": "Testing SGAI with alternative MPD and list MPDs using insert mode only. Uses List MPD as the main content with configurable insert events for alternative content.",
+ "href": "alternative/sgai-insert-test.html",
+ "image": "lib/img/bbb-2.jpg",
+ "labels": [
+ "VoD",
+ "Alternative MPD",
+ "Insert Events",
+ "List MPD",
+ "Video",
+ "Audio"
+ ]
+ }
+ ]
+ },
{
"section": "MPEG-5 Part 2 - LCEVC",
"samples": [
diff --git a/src/core/Settings.js b/src/core/Settings.js
index 838b1ca8df..fd62feaa6f 100644
--- a/src/core/Settings.js
+++ b/src/core/Settings.js
@@ -343,6 +343,9 @@ import SwitchRequest from '../streaming/rules/SwitchRequest.js';
* audioChannelConfiguration: 'urn:mpeg:mpegB:cicp:ChannelConfiguration',
* role: 'urn:mpeg:dash:role:2011',
* accessibility: 'urn:mpeg:dash:role:2011'
+ * },
+ * listMpds: {
+ * minEarliestResolutionTimeOffset: 0,
* }
* },
* errors: {
@@ -974,6 +977,16 @@ import SwitchRequest from '../streaming/rules/SwitchRequest.js';
* Maximum number of metrics that are persisted per type.
*/
+/**
+ * @typedef {Object} listMpdSettings
+ * @property {boolean} [minEarliestResolutionTimeOffset=0]
+ * Min earliest resolution time offset available for imported periods.
+ *
+ * If playback stalled during a period switch, setting this number can help fix a conflict with the GapController.
+ * It avoids a race condition between resolving linked periods and the GapController's gap jump logic.
+ * Set to 0 by default to be specification compliant. Adjust if you encounter issues with gap handling at period boundaries.
+ */
+
/**
* @typedef {Object} StreamingSettings
* @property {number} [abandonLoadTimeout=10000]
@@ -1099,6 +1112,8 @@ import SwitchRequest from '../streaming/rules/SwitchRequest.js';
* @property {module:Settings~defaultSchemeIdUri} defaultSchemeIdUri
* Default schemeIdUri for descriptor type elements
* These strings are used when not provided with setInitialMediaSettingsFor()
+ * @property {module:Settings~listMpdSettings} listMpd
+ * Settings related to List Mpd configuration
*/
@@ -1448,6 +1463,9 @@ function Settings() {
audioChannelConfiguration: 'urn:mpeg:mpegB:cicp:ChannelConfiguration',
role: 'urn:mpeg:dash:role:2011',
accessibility: 'urn:mpeg:dash:role:2011'
+ },
+ listMpd: {
+ minEarliestResolutionTimeOffset: 0
}
},
errors: {
diff --git a/src/core/events/CoreEvents.js b/src/core/events/CoreEvents.js
index 60ee8f5305..2d8b2e032b 100644
--- a/src/core/events/CoreEvents.js
+++ b/src/core/events/CoreEvents.js
@@ -40,7 +40,9 @@ import EventsBase from './EventsBase.js';
class CoreEvents extends EventsBase {
constructor () {
super();
+ this.ALTERNATIVE_EVENT_RECEIVED = 'alternativeEventReceived';
this.ATTEMPT_BACKGROUND_SYNC = 'attemptBackgroundSync';
+ this.EVENT_READY_TO_RESOLVE = 'eventReadyToResolve';
this.BUFFERING_COMPLETED = 'bufferingCompleted';
this.BUFFER_CLEARED = 'bufferCleared';
this.BUFFER_REPLACEMENT_STARTED = 'bufferReplacementStarted';
@@ -54,6 +56,12 @@ class CoreEvents extends EventsBase {
this.INIT_FRAGMENT_LOADED = 'initFragmentLoaded';
this.INIT_FRAGMENT_NEEDED = 'initFragmentNeeded';
this.INTERNAL_MANIFEST_LOADED = 'internalManifestLoaded';
+ this.ORIGINAL_ALTERNATIVE_MANIFEST_LOADED = 'originalAlternativeManifestLoaded'
+ this.ORIGINAL_MANIFEST_LOADED = 'originalManifestLoaded';
+ this.LIST_MPD_FOUND = 'listMpdFound';
+ this.LOADING_COMPLETED = 'loadingCompleted';
+ this.LOADING_PROGRESS = 'loadingProgress';
+ this.LOADING_DATA_PROGRESS = 'loadingDataProgress';
this.LOADING_ABANDONED = 'loadingAborted';
this.LOADING_COMPLETED = 'loadingCompleted';
this.LOADING_DATA_PROGRESS = 'loadingDataProgress';
diff --git a/src/dash/DashAdapter.js b/src/dash/DashAdapter.js
index e086d870db..0f5d23e4e5 100644
--- a/src/dash/DashAdapter.js
+++ b/src/dash/DashAdapter.js
@@ -325,6 +325,126 @@ function DashAdapter() {
voPeriods = getRegularPeriods(newManifest);
}
+ function mergeManifests(manifest, importedManifest, period, mpdHasDuration) {
+ const voPeriods = getRegularPeriods(manifest);
+ const periodIndex = voPeriods.findIndex(voperiod => voperiod.id === period.id);
+
+ if (periodIndex === -1) {
+ // Period not found
+ return;
+ }
+
+ let newPeriod = {};
+ const linkedPeriod = voPeriods[periodIndex];
+
+ if (importedManifest) {
+ // availabilityEndTime must be greater than current time
+ if (importedManifest.availabilityEndTime?.getTime() < Date.now()) {
+ return;
+ }
+
+ const importedPeriod = importedManifest.Period[0];
+
+ if (importedManifest.hasOwnProperty(DashConstants.PROFILES)) {
+ importedPeriod.profiles = importedManifest.profiles;
+
+ const existingProfiles = manifest.profiles ? manifest.profiles.split(',') : [];
+ const importedProfiles = importedManifest.profiles ? importedManifest.profiles.split(',') : [];
+ const uniqueProfiles = new Set([...existingProfiles, ...importedProfiles]);
+
+ manifest.profiles = Array.from(uniqueProfiles).join(',');
+ }
+
+ if (importedManifest.EssentialProperty) {
+ importedPeriod.EssentialProperty = importedPeriod.EssentialProperty ? importedManifest.EssentialProperty.concat(importedPeriod.EssentialProperty) : importedManifest.EssentialProperty;
+ }
+
+ if (importedManifest.SupplementalProperty) {
+ importedPeriod.SupplementalProperty = importedPeriod.SupplementalProperty ? importedManifest.SupplementalProperty.concat(importedPeriod.SupplementalProperty) : importedManifest.SupplementalProperty;
+ }
+
+ newPeriod = {
+ baseUri: importedManifest.baseUri,
+ minBufferTime: importedManifest.minBufferTime,
+ start: linkedPeriod.start ?? importedPeriod.start,
+ id: linkedPeriod.id ?? importedPeriod.id,
+ duration: linkedPeriod.duration,
+ ServiceDescription: period.ServiceDescription || [],
+ SupplementalProperty: period.SupplementalProperty || [],
+ EssentialProperty: period.EssentialProperty || [],
+ EventStream: period.EventStream || [],
+ };
+
+ // Update duration
+ if (importedPeriod.duration && importedPeriod.duration < linkedPeriod.duration) {
+ newPeriod.duration = importedPeriod.duration;
+ if (!mpdHasDuration) {
+ manifest.mediaPresentationDuration += importedPeriod.duration - linkedPeriod.duration;
+ }
+ }
+
+ // Merge custom namespace properties
+ Object.keys(linkedPeriod)
+ .filter(name => name.includes(':'))
+ .forEach(name => newPeriod[name] = linkedPeriod[name]);
+
+ _mergeEquivalentProperties(newPeriod.ServiceDescription, importedPeriod.ServiceDescription, DashConstants.ID);
+ _mergeEquivalentProperties(newPeriod.SupplementalProperty, importedPeriod.SupplementalProperty, Constants.SCHEME_ID_URI, DashConstants.VALUE);
+ _mergeEquivalentProperties(newPeriod.EssentialProperty, importedPeriod.EssentialProperty, Constants.SCHEME_ID_URI, DashConstants.VALUE);
+ _mergeEquivalentProperties(newPeriod.EventStream, importedPeriod.EventStream, Constants.SCHEME_ID_URI, DashConstants.VALUE);
+
+ removeEmptyProperties(newPeriod, [
+ DashConstants.SERVICE_DESCRIPTION,
+ DashConstants.SUPPLEMENTAL_PROPERTY,
+ DashConstants.ESSENTIAL_PROPERTY,
+ DashConstants.EVENT_STREAM
+ ]);
+
+ newPeriod.baseURL = importedManifest.baseUri;
+ if (importedManifest.BaseURL || importedPeriod.BaseURL) {
+ newPeriod.BaseURL = [...(importedManifest.BaseURL || []), ...(importedPeriod.BaseURL || [])].filter(Boolean);
+ }
+
+ newPeriod.AdaptationSet = importedPeriod.AdaptationSet;
+ } else {
+ newPeriod = { ...linkedPeriod };
+ delete newPeriod.ImportedMPD;
+ delete newPeriod.earliestResolutionTimeOffset;
+ }
+
+ if (newPeriod.minBufferTime && (newPeriod.AdaptationSet || newPeriod.duration === 0)) {
+ manifest.Period[periodIndex] = newPeriod;
+ } else {
+ manifest.Period.splice(periodIndex, 1);
+ }
+ }
+
+ function _mergeEquivalentProperties(targetArray, sourceArray, keyProp, valueProp) {
+ if (!sourceArray || !targetArray) {
+ return;
+ }
+
+ for (const item of sourceArray) {
+ const index = targetArray.findIndex(existingItem => existingItem[keyProp] === item[keyProp] && (!valueProp || existingItem[valueProp] === item[valueProp]));
+ if (index !== -1) {
+ // Replace existing entry
+ targetArray[index] = item;
+ } else {
+ targetArray.push(item);
+ }
+ }
+ }
+
+ // Helper function to remove empty properties
+ function removeEmptyProperties(obj, propertyNames) {
+ for (const prop of propertyNames) {
+ if (Array.isArray(obj[prop]) && obj[prop].length === 0) {
+ delete obj[prop];
+ }
+ }
+ }
+
+
/**
* Returns an array of streamInfo objects
* @param {object} externalManifest
@@ -617,6 +737,18 @@ function DashAdapter() {
return dashManifestModel.getDuration(manifest);
}
+ /**
+ * Returns all linked periods of the MPD
+ * @param {object} externalManifest Omit this value if no external manifest should be used
+ * @returns {Array} linked periods
+ * @memberOf module:DashAdapter
+ * @instance
+ */
+ function getLinkedPeriods(externalManifest) {
+ const mpd = getMpd(externalManifest);
+ return dashManifestModel.getLinkedPeriods(mpd);
+ }
+
/**
* Returns all periods of the MPD
* @param {object} externalManifest Omit this value if no external manifest should be used
@@ -1329,6 +1461,7 @@ function DashAdapter() {
getIsTextTrack,
getIsTypeOf,
getLocation,
+ getLinkedPeriods,
getMainAdaptationForType,
getMainAdaptationSetForPreselection,
getCommonRepresentationForPreselection,
@@ -1350,6 +1483,7 @@ function DashAdapter() {
getUTCTimingSources,
getVoRepresentations,
isPatchValid,
+ mergeManifests,
reset,
setConfig,
updatePeriods,
diff --git a/src/dash/constants/DashConstants.js b/src/dash/constants/DashConstants.js
index 6ed11d38d5..ea38529cfb 100644
--- a/src/dash/constants/DashConstants.js
+++ b/src/dash/constants/DashConstants.js
@@ -39,6 +39,11 @@ export default {
ADAPTATION_SETS: 'adaptationSets',
ADAPTATION_SET_SWITCHING_SCHEME_ID_URI: 'urn:mpeg:dash:adaptation-set-switching:2016',
ADD: 'add',
+ ALTERNATIVE_MPD: {
+ INSERT: 'InsertPresentation',
+ REPLACE: 'ReplacePresentation',
+ },
+ ALTERNATIVE_MPD_SCHEME_ID: 'urn:mpeg:dash:event:alternativeMPD:2022',
ASSET_IDENTIFIER: 'AssetIdentifier',
AUDIO_CHANNEL_CONFIGURATION: 'AudioChannelConfiguration',
AUDIO_SAMPLING_RATE: 'audioSamplingRate',
@@ -104,6 +109,7 @@ export default {
LA_URL_LOWER_CASE: 'laurl',
LABEL: 'Label',
LANG: 'lang',
+ LIST_PROFILE_SCHEME: 'urn:mpeg:dash:profile:list:2024',
LOCATION: 'Location',
MAIN: 'main',
MAXIMUM_SAP_PERIOD: 'maximumSAPPeriod',
@@ -121,6 +127,7 @@ export default {
MIN_BUFFER_TIME: 'minBufferTime',
MP4_PROTECTION_SCHEME: 'urn:mpeg:dash:mp4protection:2011',
MPD: 'MPD',
+ MPD_LIST: 'list',
MPD_TYPE: 'mpd',
MPD_PATCH_TYPE: 'mpdpatch',
ORDER: 'order',
@@ -181,6 +188,7 @@ export default {
START_NUMBER: 'startNumber',
START_WITH_SAP: 'startWithSAP',
STATIC: 'static',
+ STATUS: 'status',
STEERING_TYPE: 'steering',
SUBSET: 'Subset',
SUBTITLE: 'subtitle',
diff --git a/src/dash/models/DashManifestModel.js b/src/dash/models/DashManifestModel.js
index 5d0b1bbca9..e3bc83419c 100644
--- a/src/dash/models/DashManifestModel.js
+++ b/src/dash/models/DashManifestModel.js
@@ -51,6 +51,7 @@ import Period from '../vo/Period.js';
import Preselection from '../vo/Preselection.js';
import ProducerReferenceTime from '../vo/ProducerReferenceTime.js';
import Representation from '../vo/Representation.js';
+import AlternativeMpd from '../vo/AlternativeMpd.js';
import URLUtils from '../../streaming/utils/URLUtils.js';
import UTCTiming from '../vo/UTCTiming.js';
import Utils from '../../core/Utils.js';
@@ -389,10 +390,11 @@ function DashManifestModel() {
let i,
len;
const adaptations = [];
-
- for (i = 0, len = realAdaptations.length; i < len; i++) {
- if (getIsTypeOf(realAdaptations[i], type)) {
- adaptations.push(processAdaptation(realAdaptations[i]));
+ if (realAdaptations) {
+ for (i = 0, len = realAdaptations.length; i < len; i++) {
+ if (getIsTypeOf(realAdaptations[i], type)) {
+ adaptations.push(processAdaptation(realAdaptations[i]));
+ }
}
}
@@ -1121,7 +1123,7 @@ function DashManifestModel() {
// If the attribute @start is present in the Period, then the
// Period is a regular Period and the PeriodStart is equal
// to the value of this attribute.
- if (realPeriod.hasOwnProperty(DashConstants.START)) {
+ if (realPeriod.hasOwnProperty(DashConstants.START) && !isNaN(realPeriod.start)) {
voPeriod = new Period();
voPeriod.start = realPeriod.start;
}
@@ -1184,6 +1186,30 @@ function DashManifestModel() {
return voPeriods;
}
+ function getLinkedPeriods(mpd) {
+ const linkedPeriods = []
+
+ if (!mpd || !mpd.manifest || !mpd.manifest.Period) {
+ return linkedPeriods;
+ }
+
+ let currentPeriod = null;
+ for (let i = 0, len = mpd.manifest.Period.length; i < len; i++) {
+ currentPeriod = mpd.manifest.Period[i];
+ if (currentPeriod.ImportedMPD) {
+ linkedPeriods.push(currentPeriod);
+ }
+ }
+
+ if (linkedPeriods.length > 0) {
+ if (mpd.manifest.type !== DashConstants.MPD_LIST) {
+ throw new Error(`Linked periods are only allowed in an MPD with profile ${DashConstants.MPD_LIST}`);
+ }
+ }
+
+ return linkedPeriods
+ }
+
function getPeriodId(realPeriod, i) {
if (!realPeriod) {
throw new Error('Period cannot be null or undefined');
@@ -1316,6 +1342,21 @@ function DashManifestModel() {
} else {
event.id = null;
}
+ if (currentMpdEvent.hasOwnProperty(DashConstants.STATUS)) {
+ event.status = currentMpdEvent.status;
+ } else {
+ event.status = null;
+ }
+
+ const alternativeMpdKey = Object.keys(DashConstants.ALTERNATIVE_MPD).find(key =>
+ currentMpdEvent.hasOwnProperty(DashConstants.ALTERNATIVE_MPD[key])
+ );
+
+ if (alternativeMpdKey) {
+ event.alternativeMpd = getAlternativeMpd(currentMpdEvent[DashConstants.ALTERNATIVE_MPD[alternativeMpdKey]], DashConstants.ALTERNATIVE_MPD[alternativeMpdKey]);
+ } else {
+ event.alternativeMpd = null;
+ }
if (currentMpdEvent.Signal && currentMpdEvent.Signal.Binary && currentMpdEvent.Signal.Binary.__text) {
// toString is used to manage both regular and namespaced tags
@@ -1339,6 +1380,41 @@ function DashManifestModel() {
return events;
}
+ function getAlternativeMpd(event, mode) {
+ if (!mode) {
+ return
+ }
+ const alternativeMpd = new AlternativeMpd();
+
+ getAlternativeMpdCommonData(alternativeMpd, event);
+
+ // Keep to avoid errors with the old signaling
+ alternativeMpd.disableJumpTimeOffest = event.disableJumpTimeOffest ?? null;
+ alternativeMpd.playTimes = event.playTimes ?? null;
+
+ if (mode === DashConstants.ALTERNATIVE_MPD.INSERT) {
+ alternativeMpd.mode = Constants.ALTERNATIVE_MPD.MODES.INSERT;
+ return alternativeMpd;
+ }
+
+ if (mode === DashConstants.ALTERNATIVE_MPD.REPLACE) {
+ alternativeMpd.mode = Constants.ALTERNATIVE_MPD.MODES.REPLACE;
+ alternativeMpd.returnOffset = event.returnOffset ?? null;
+ alternativeMpd.clip = event.clip ? !(event.clip === 'false') : true;
+ alternativeMpd.startWithOffset = event.startWithOffset ? event.startWithOffset === 'true' : false;
+ return alternativeMpd;
+ }
+ }
+
+ function getAlternativeMpdCommonData(alternativeMpd, event) {
+ alternativeMpd.url = event.url ?? null;
+ alternativeMpd.earliestResolutionTimeOffset = event.earliestResolutionTimeOffset / 1000 ?? null;
+ alternativeMpd.serviceDescriptionId = event.serviceDescriptionId;
+ alternativeMpd.maxDuration = event.maxDuration;
+ alternativeMpd.noJump = event.noJump;
+ alternativeMpd.executeOnce = event.executeOnce ? event.executeOnce === 'true' : false;
+ }
+
function getEventStreams(inbandStreams, representation, period) {
const eventStreams = [];
let i;
@@ -1734,6 +1810,7 @@ function DashManifestModel() {
getIsTypeOf,
getLabelsForAdaptation,
getLanguageForAdaptation,
+ getLinkedPeriods,
getLocation,
getMainAdaptationSetForPreselection,
getCommonRepresentationForPreselection,
diff --git a/src/dash/vo/AlternativeMpd.js b/src/dash/vo/AlternativeMpd.js
new file mode 100644
index 0000000000..f36badd763
--- /dev/null
+++ b/src/dash/vo/AlternativeMpd.js
@@ -0,0 +1,57 @@
+/**
+ * The copyright in this software is being made available under the BSD License,
+ * included below. This software may be subject to other third party and contributor
+ * rights, including patent rights, and no such rights are granted under this license.
+ *
+ * Copyright (c) 2013, Dash Industry Forum.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ * * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation and/or
+ * other materials provided with the distribution.
+ * * Neither the name of Dash Industry Forum nor the names of its
+ * contributors may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+/**
+ * @class
+ * @ignore
+ */
+
+class AlternativeMpd {
+ constructor() {
+ this.url = '';
+ this.earliestResolutionTimeOffset = NaN;
+ this.mode = '';
+ this.maxDuration = '';
+ this.serviceDescriptionId = NaN;
+ this.executeOnce = false;
+
+ // Replace
+ this.returnOffset = NaN;
+ this.returnOffset = NaN;
+ this.clip = true;
+ this.startWithOffset = false;
+
+ // Old attributes
+ this.disableJumpTimeOffest = NaN;
+ this.playTimes = '';
+ }
+}
+
+export default AlternativeMpd;
diff --git a/src/dash/vo/Event.js b/src/dash/vo/Event.js
index 28e40c7eba..234007091d 100644
--- a/src/dash/vo/Event.js
+++ b/src/dash/vo/Event.js
@@ -42,7 +42,8 @@ class Event {
this.eventStream = null;
this.presentationTimeDelta = NaN; // Specific EMSG Box parameter
this.parsedMessageData = null; // Parsed value of the event message
+ this.alternativeMpd = null;
}
}
-export default Event;
\ No newline at end of file
+export default Event;
diff --git a/src/streaming/ManifestLoader.js b/src/streaming/ManifestLoader.js
index 94b69b6876..89596df514 100644
--- a/src/streaming/ManifestLoader.js
+++ b/src/streaming/ManifestLoader.js
@@ -107,7 +107,7 @@ function ManifestLoader(config) {
}
}
- function load(url, serviceLocation = null, queryParams = null) {
+ function load(url, serviceLocation = null, queryParams = null, linkedPeriod = null, alternative = false) {
const requestStartDate = new Date();
const request = new TextRequest(url, HTTPRequest.MPD_TYPE);
@@ -124,129 +124,141 @@ function ManifestLoader(config) {
request.startDate = requestStartDate;
}
- eventBus.trigger(
- Events.MANIFEST_LOADING_STARTED, {
- request
- }
- );
-
- urlLoader.load({
- request: request,
- success: function (data, textStatus, responseURL) {
- // Manage situations in which success is called after calling reset
- if (!xlinkController) {
- return;
- }
-
- let actualUrl,
- baseUri,
- manifest;
-
- // Handle redirects for the MPD - as per RFC3986 Section 5.1.3
- // also handily resolves relative MPD URLs to absolute
- if (responseURL && responseURL !== url) {
- baseUri = urlUtils.parseBaseUrl(responseURL);
- actualUrl = responseURL;
- } else {
- // usually this case will be caught and resolved by
- // responseURL above but it is not available for IE11 and Edge/12 and Edge/13
- // baseUri must be absolute for BaseURL resolution later
- if (urlUtils.isRelative(url)) {
- url = urlUtils.resolve(url, window.location.href);
+ function createUrlLoaderObject(resolve, reject) {
+ return {
+ request: request,
+ success: function (data, textStatus, responseURL) {
+ // Manage situations in which success is called after calling reset
+ if (!xlinkController) {
+ return;
}
- baseUri = urlUtils.parseBaseUrl(url);
- }
-
- // A response of no content implies in-memory is properly up to date
- if (textStatus == 'No Content') {
- eventBus.trigger(
- Events.INTERNAL_MANIFEST_LOADED, {
- manifest: null
+ let actualUrl,
+ baseUri,
+ manifest;
+
+ // Handle redirects for the MPD - as per RFC3986 Section 5.1.3
+ // also handily resolves relative MPD URLs to absolute
+ if (responseURL && responseURL !== url) {
+ baseUri = urlUtils.parseBaseUrl(responseURL);
+ actualUrl = responseURL;
+ } else {
+ // usually this case will be caught and resolved by
+ // responseURL above but it is not available for IE11 and Edge/12 and Edge/13
+ // baseUri must be absolute for BaseURL resolution later
+ if (urlUtils.isRelative(url)) {
+ url = urlUtils.resolve(url, window.location.href);
}
- );
- return;
- }
-
- // Create parser according to manifest type
- if (parser === null) {
- parser = createParser(data);
- }
-
- if (parser === null) {
- eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {
- manifest: null,
- error: new DashJSError(
- Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE,
- Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}`
- )
- });
- return;
- }
- // init xlinkcontroller with created parser
- xlinkController.setParser(parser);
-
- try {
- manifest = parser.parse(data);
- } catch (e) {
- eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {
- manifest: null,
- error: new DashJSError(
- Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE,
- Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}`
- )
- });
- return;
- }
+ baseUri = urlUtils.parseBaseUrl(url);
+ }
- if (manifest) {
- manifest.url = actualUrl || url;
+ // Create parser according to manifest type
+ if (parser === null) {
+ parser = createParser(data);
+ }
- // URL from which the MPD was originally retrieved (MPD updates will not change this value)
- if (!manifest.originalUrl) {
- manifest.originalUrl = manifest.url;
+ if (parser === null && !linkedPeriod) {
+ eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {
+ manifest: null,
+ error: new DashJSError(
+ Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE,
+ Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}`
+ )
+ });
+ return;
}
- // If there is a mismatch between the manifest's specified duration and the total duration of all periods,
- // and the specified duration is greater than the total duration of all periods,
- // overwrite the manifest's duration attribute. This is a patch for if a manifest is generated incorrectly.
- if (settings &&
- settings.get().streaming.enableManifestDurationMismatchFix &&
- manifest.mediaPresentationDuration &&
- manifest.Period.length > 1) {
- const sumPeriodDurations = manifest.Period.reduce((totalDuration, period) => totalDuration + period.duration, 0);
- if (!isNaN(sumPeriodDurations) && manifest.mediaPresentationDuration > sumPeriodDurations) {
- logger.warn('Media presentation duration greater than duration of all periods. Setting duration to total period duration');
- manifest.mediaPresentationDuration = sumPeriodDurations;
+ // init xlinkcontroller with created parser
+ xlinkController.setParser(parser);
+
+ try {
+ manifest = parser.parse(data);
+ } catch (e) {
+ if (!linkedPeriod) {
+ eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {
+ manifest: null,
+ error: new DashJSError(
+ Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE,
+ Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}`
+ )
+ });
}
+ return;
}
- manifest.baseUri = baseUri;
- manifest.loadedTime = new Date();
- xlinkController.resolveManifestOnLoad(manifest);
-
- eventBus.trigger(Events.ORIGINAL_MANIFEST_LOADED, { originalManifest: data });
- } else {
- eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {
- manifest: null,
- error: new DashJSError(
- Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE,
- Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}`
- )
- });
+ if (manifest) {
+ manifest.url = actualUrl || url;
+
+ // URL from which the MPD was originally retrieved (MPD updates will not change this value)
+ if (!manifest.originalUrl) {
+ manifest.originalUrl = manifest.url;
+ }
+
+ // If there is a mismatch between the manifest's specified duration and the total duration of all periods,
+ // and the specified duration is greater than the total duration of all periods,
+ // overwrite the manifest's duration attribute. This is a patch for if a manifest is generated incorrectly.
+ if (settings &&
+ settings.get().streaming.enableManifestDurationMismatchFix &&
+ manifest.mediaPresentationDuration &&
+ manifest.Period.length > 1) {
+ const sumPeriodDurations = manifest.Period.reduce((totalDuration, period) => totalDuration + period.duration, 0);
+ if (!isNaN(sumPeriodDurations) && manifest.mediaPresentationDuration > sumPeriodDurations) {
+ logger.warn('Media presentation duration greater than duration of all periods. Setting duration to total period duration');
+ manifest.mediaPresentationDuration = sumPeriodDurations;
+ }
+ }
+
+ manifest.baseUri = baseUri;
+ manifest.loadedTime = new Date();
+ if (!linkedPeriod) {
+ xlinkController.resolveManifestOnLoad(manifest);
+ if (alternative) {
+ eventBus.trigger(Events.ORIGINAL_ALTERNATIVE_MANIFEST_LOADED, { manifest: data });
+ } else {
+ eventBus.trigger(Events.ORIGINAL_MANIFEST_LOADED, { originalManifest: data });
+ }
+ } else {
+ resolve(manifest);
+ }
+ } else if (!linkedPeriod) {
+ eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {
+ manifest: null,
+ error: new DashJSError(
+ Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE,
+ Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}`
+ )
+ });
+ }
+ },
+ error: function (request, statusText, errorText) {
+ if (!linkedPeriod) {
+ eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {
+ manifest: null,
+ error: new DashJSError(
+ Errors.MANIFEST_LOADER_LOADING_FAILURE_ERROR_CODE,
+ Errors.MANIFEST_LOADER_LOADING_FAILURE_ERROR_MESSAGE + `${url}, ${errorText}`
+ )
+ });
+ } else {
+ reject();
+ }
}
- },
- error: function (request, statusText, errorText) {
- eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, {
- manifest: null,
- error: new DashJSError(
- Errors.MANIFEST_LOADER_LOADING_FAILURE_ERROR_CODE,
- Errors.MANIFEST_LOADER_LOADING_FAILURE_ERROR_MESSAGE + `${url}, ${errorText}`
- )
- });
}
- });
+ }
+
+ if (linkedPeriod) {
+ return new Promise((resolve, reject) => {
+ urlLoader.load(createUrlLoaderObject(resolve, reject));
+ });
+ } else {
+ eventBus.trigger(
+ Events.MANIFEST_LOADING_STARTED, {
+ request
+ }
+ );
+ urlLoader.load(createUrlLoaderObject());
+ }
}
function reset() {
@@ -269,7 +281,7 @@ function ManifestLoader(config) {
instance = {
load: load,
- reset: reset
+ reset: reset,
};
setup();
diff --git a/src/streaming/ManifestUpdater.js b/src/streaming/ManifestUpdater.js
index 27a161d7fc..339c0f1c8f 100644
--- a/src/streaming/ManifestUpdater.js
+++ b/src/streaming/ManifestUpdater.js
@@ -262,8 +262,14 @@ function ManifestUpdater() {
if (refreshDelay * 1000 > 0x7FFFFFFF) {
refreshDelay = 0x7FFFFFFF / 1000;
}
- eventBus.trigger(Events.MANIFEST_UPDATED, { manifest: manifest });
- logger.info('Manifest has been refreshed at ' + date + '[' + date.getTime() / 1000 + '] ');
+
+ const manifestProfiles = manifest.profiles ? manifest.profiles.split(',') : [];
+ if (manifestProfiles.includes(DashConstants.LIST_PROFILE_SCHEME)) {
+ eventBus.trigger(Events.LIST_MPD_FOUND, { manifest });
+ } else {
+ eventBus.trigger(Events.MANIFEST_UPDATED, { manifest: manifest });
+ logger.info('Manifest has been refreshed at ' + date + '[' + date.getTime() / 1000 + '] ');
+ }
if (!isPaused) {
startManifestRefreshTimer();
diff --git a/src/streaming/MediaManager.js b/src/streaming/MediaManager.js
new file mode 100644
index 0000000000..3a4a33d05c
--- /dev/null
+++ b/src/streaming/MediaManager.js
@@ -0,0 +1,318 @@
+/**
+ * The copyright in this software is being made available under the BSD License,
+ * included below. This software may be subject to other third party and contributor
+ * rights, including patent rights, and no such rights are granted under this license.
+ *
+ * Copyright (c) 2013, Dash Industry Forum.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ * * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation and/or
+ * other materials provided with the distribution.
+ * * Neither the name of Dash Industry Forum nor the names of its
+ * contributors may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+import Events from '../core/events/Events.js';
+import MediaPlayerEvents from './MediaPlayerEvents.js';
+import MediaPlayer from './MediaPlayer.js';
+import FactoryMaker from '../core/FactoryMaker.js';
+import Debug from '../core/Debug.js';
+
+function MediaManager() {
+ let instance,
+ videoModel,
+ isSwitching = false,
+ hideAlternativePlayerControls = false,
+ altPlayer,
+ playbackController,
+ altVideoElement,
+ alternativeContext,
+ logger,
+ debug,
+ prebufferedPlayers = new Map(),
+ prebufferCleanupInterval = null;
+
+ const context = this.context;
+
+ function setConfig(config) {
+ if (!config) {
+ return;
+ }
+
+ if (!videoModel) {
+ videoModel = config.videoModel;
+ }
+
+ if (config.debug) {
+ debug = config.debug;
+ }
+
+ if (!!config.playbackController && !playbackController) {
+ playbackController = config.playbackController;
+ }
+
+ if (!!config.hideAlternativePlayerControls && !hideAlternativePlayerControls) {
+ hideAlternativePlayerControls = config.hideAlternativePlayerControls;
+ }
+
+ if (!!config.alternativeContext && !alternativeContext) {
+ alternativeContext = config.alternativeContext
+ }
+ }
+
+ function initialize() {
+ if (!debug) {
+ debug = Debug(context).getInstance();
+ }
+
+ logger = debug.getLogger(instance);
+
+ document.addEventListener('fullscreenchange', () => {
+ if (document.fullscreenElement === videoModel.getElement()) {
+ // TODO: Implement fullscreen
+ } else {
+ // TODO: Handle error
+ }
+ });
+ }
+
+
+ function prebufferAlternativeContent(playerId, alternativeMpdUrl) {
+ try {
+ if (prebufferedPlayers.has(playerId)) {
+ return;
+ }
+
+ logger.info(`Starting prebuffering for player ${playerId}`);
+
+ // Create a prebuffered player
+ const prebufferedPlayer = MediaPlayer().create();
+ prebufferedPlayer.initialize(null, alternativeMpdUrl, false, NaN);
+ prebufferedPlayer.updateSettings({
+ streaming: {
+ cacheInitSegments: true
+ }
+ });
+ prebufferedPlayer.preload();
+ prebufferedPlayer.setAutoPlay(false);
+
+ // Store the prebuffered player
+ prebufferedPlayers.set(playerId, {
+ player: prebufferedPlayer,
+ playerId: playerId
+ });
+
+ prebufferedPlayer.on(Events.STREAM_INITIALIZED, () => {
+ logger.info(`Prebuffering completed for player ${playerId}`);
+ }, this);
+
+ prebufferedPlayer.on(Events.ERROR, (e) => {
+ logger.error(`Prebuffering error for player ${playerId}:`, e);
+ cleanupPrebufferedContent(playerId);
+ }, this);
+
+ } catch (err) {
+ logger.error('Error prebuffering alternative content:', err);
+ }
+ }
+
+ function cleanupPrebufferedContent(playerId) {
+ try {
+ const prebufferedPlayer = prebufferedPlayers.get(playerId);
+ if (prebufferedPlayer) {
+ prebufferedPlayer.player.off(Events.STREAM_INITIALIZED);
+ prebufferedPlayer.player.off(Events.ERROR);
+ prebufferedPlayer.player.reset();
+
+ prebufferedPlayers.delete(playerId);
+ }
+ logger.debug(`Cleaned up prebuffered content for ${playerId}`);
+ } catch (err) {
+ logger.error('Error cleaning up prebuffered content:', err);
+ }
+ }
+
+ function initializeAlternativePlayer(alternativeMpdUrl) {
+ if (altPlayer) {
+ altPlayer.off(Events.ERROR, onAlternativePlayerError, this);
+ }
+ altPlayer = MediaPlayer().create();
+ altPlayer.updateSettings({
+ streaming: {
+ cacheInitSegments: true
+ }
+ });
+ altPlayer.initialize(null, alternativeMpdUrl, false, NaN);
+ altPlayer.preload();
+ altPlayer.setAutoPlay(false);
+ altPlayer.on(Events.ERROR, onAlternativePlayerError, this);
+ }
+
+ function onAlternativePlayerError(e) {
+ if (logger) {
+ logger.error('Alternative player error:', e);
+ }
+ }
+
+ function switchToAlternativeContent(playerId, alternativeMpdUrl, time = 0) {
+ if (isSwitching) {
+ logger.debug('Switch already in progress - ignoring request');
+ return
+ };
+
+ logger.info(`Switching to alternative content at time ${time}`);
+ isSwitching = true;
+
+ const prebufferedContent = prebufferedPlayers.get(playerId);
+
+ if (prebufferedContent) {
+ logger.info(`Using prebuffered content for player ${playerId}`);
+ altPlayer = prebufferedContent.player;
+ prebufferedPlayers.delete(playerId);
+ } else {
+ initializeAlternativePlayer(alternativeMpdUrl);
+ }
+
+ if (!altVideoElement) {
+ altVideoElement = document.createElement('video');
+ const videoElement = videoModel.getElement();
+ const parentNode = videoElement && videoElement.parentNode;
+ if (parentNode) {
+ parentNode.insertBefore(altVideoElement, videoElement.nextSibling);
+ }
+ }
+
+ if (altPlayer) {
+ altVideoElement.style.display = 'block';
+ altPlayer.attachView(altVideoElement);
+ }
+
+ videoModel.pause();
+ videoModel.getElement().style.display = 'none';
+ logger.debug('Main video paused');
+
+ if (time) {
+ logger.debug(`Seeking alternative content to time: ${time}`);
+ altPlayer.seek(time);
+ }
+
+ altPlayer.play();
+ logger.info(`Alternative content playback started for player ${playerId}`);
+
+ isSwitching = false;
+ }
+
+
+ function switchBackToMainContent(seekTime) {
+ if (isSwitching) {
+ logger.debug('Switch already in progress - ignoring request');
+ return
+ };
+
+ if (!altPlayer) {
+ logger.warn('No alternative player to switch back from');
+ return;
+ }
+
+ logger.info('Switching back to main content');
+ isSwitching = true;
+
+ altPlayer.pause();
+ altVideoElement.style.display = 'none';
+ videoModel.getElement().style.display = 'block';
+
+ if (playbackController.getIsDynamic()) {
+ logger.debug('Seeking to original live point for dynamic manifest');
+ if (seekTime > playbackController.getDvrWindowStart()) {
+ playbackController.seek(seekTime, false, false);
+ } else {
+ logger.warn('Seek time is before DVR window start, seeking to start of DVR window');
+ playbackController.seekToDvrWindowStart();
+ }
+ } else {
+ logger.debug(`Seeking main content to time: ${seekTime}`);
+ playbackController.seek(seekTime, false, false);
+ }
+
+ videoModel.play();
+ logger.info('Main content playback resumed');
+
+ altPlayer.destroy();
+ altPlayer = null;
+
+ isSwitching = false;
+
+ logger.debug('Alternative player resources cleaned up');
+ }
+
+
+ function reset() {
+ if (altPlayer) {
+ altPlayer.off(MediaPlayerEvents.PLAYBACK_TIME_UPDATED);
+ altPlayer.off(Events.ERROR, onAlternativePlayerError, this);
+ altPlayer.reset();
+ altPlayer = null;
+ }
+
+ if (altVideoElement) {
+ altVideoElement.style.display = 'none';
+ }
+
+ // Clean up all prebuffered content
+ for (const [playerId] of prebufferedPlayers) {
+ cleanupPrebufferedContent(playerId);
+ }
+ prebufferedPlayers.clear();
+
+ // Clear cleanup interval
+ if (prebufferCleanupInterval) {
+ clearInterval(prebufferCleanupInterval);
+ prebufferCleanupInterval = null;
+ }
+
+ isSwitching = false;
+ }
+
+ function getAlternativePlayer() {
+ return altPlayer;
+ }
+
+ function setAlternativeVideoElement(alternativeVideoElement) {
+ altVideoElement = alternativeVideoElement;
+ }
+
+ instance = {
+ setConfig,
+ initialize,
+ prebufferAlternativeContent,
+ cleanupPrebufferedContent,
+ switchToAlternativeContent,
+ switchBackToMainContent,
+ getAlternativePlayer,
+ setAlternativeVideoElement,
+ reset
+ };
+
+ return instance;
+}
+
+MediaManager.__dashjs_factory_name = 'MediaManager';
+const factory = FactoryMaker.getSingletonFactory(MediaManager);
+FactoryMaker.updateSingletonFactory(MediaManager.__dashjs_factory_name, factory);
+export default factory;
diff --git a/src/streaming/MediaPlayer.js b/src/streaming/MediaPlayer.js
index 22d7db881a..b0633a64df 100644
--- a/src/streaming/MediaPlayer.js
+++ b/src/streaming/MediaPlayer.js
@@ -29,6 +29,7 @@
* POSSIBILITY OF SUCH DAMAGE.
*/
import AbrController from './controllers/AbrController.js';
+import AlternativeMediaController from './controllers/AlternativeMediaController.js';
import BASE64 from '../../externals/base64.js';
import BaseURLController from './controllers/BaseURLController.js';
import BoxParser from './utils/BoxParser.js';
@@ -41,11 +42,11 @@ import CmsdModel from './models/CmsdModel.js';
import Constants from './constants/Constants.js';
import ContentSteeringController from '../dash/controllers/ContentSteeringController.js';
import CustomParametersModel from './models/CustomParametersModel.js';
-import DOMStorage from './utils/DOMStorage.js';
import DashAdapter from '../dash/DashAdapter.js';
import DashConstants from '../dash/constants/DashConstants.js';
import DashJSError from './vo/DashJSError.js';
import DashMetrics from '../dash/DashMetrics.js';
+import DOMStorage from './utils/DOMStorage.js';
import Debug from './../core/Debug.js';
import ErrorHandler from './utils/ErrorHandler.js';
import Errors from './../core/errors/Errors.js';
@@ -55,6 +56,7 @@ import ExternalSubtitle from './vo/ExternalSubtitle.js';
import FactoryMaker from '../core/FactoryMaker.js';
import GapController from './controllers/GapController.js';
import ISOBoxer from 'codem-isoboxer';
+import ListMpdController from './controllers/ListMpdController.js';
import ManifestLoader from './ManifestLoader.js';
import ManifestModel from './models/ManifestModel.js';
import ManifestUpdater from './ManifestUpdater.js';
@@ -140,6 +142,7 @@ function MediaPlayer() {
throughputController,
schemeLoaderFactory,
timelineConverter,
+ alternativeMediaController,
mediaController,
protectionController,
metricsReportingController,
@@ -159,6 +162,7 @@ function MediaPlayer() {
serviceDescriptionController,
contentSteeringController,
catchupController,
+ listMpdController,
dashMetrics,
manifestModel,
cmcdModel,
@@ -221,6 +225,9 @@ function MediaPlayer() {
if (config.gapController) {
gapController = config.gapController;
}
+ if (config.alternativeMediaController) {
+ alternativeMediaController = config.alternativeMediaController;
+ }
if (config.throughputController) {
throughputController = config.throughputController
}
@@ -317,6 +324,10 @@ function MediaPlayer() {
schemeLoaderFactory = SchemeLoaderFactory(context).getInstance();
}
+ if (!alternativeMediaController) {
+ alternativeMediaController = AlternativeMediaController(context).getInstance();
+ }
+
if (!playbackController) {
playbackController = PlaybackController(context).getInstance();
}
@@ -387,6 +398,15 @@ function MediaPlayer() {
adapter
});
+ alternativeMediaController.setConfig({
+ videoModel,
+ DashConstants,
+ mediaPlayerFactory: FactoryMaker.getClassFactory(MediaPlayer)(),
+ playbackController,
+ alternativeContext: context,
+ logger
+ });
+
if (!segmentBaseController) {
segmentBaseController = SegmentBaseController(context).getInstance({
dashMetrics: dashMetrics,
@@ -454,8 +474,12 @@ function MediaPlayer() {
* @memberof module:MediaPlayer
* @instance
*/
- function reset() {
- attachSource(null);
+ function reset(onlyControllers) {
+
+ if (!onlyControllers) {
+ attachSource(null);
+ }
+
attachView(null);
protectionData = null;
if (protectionController) {
@@ -582,12 +606,13 @@ function MediaPlayer() {
* @throws {@link module:MediaPlayer~SOURCE_NOT_ATTACHED_ERROR SOURCE_NOT_ATTACHED_ERROR} if called before attachSource function
* @instance
*/
- function preload() {
- if (videoModel.getElement() || streamingInitialized) {
+ function preload(time) {
+ if (videoModel.getElement() || (streamingInitialized && !time)) {
return;
}
if (source) {
- _initializePlayback(providedStartTime);
+ const playbackTime = time ? time : providedStartTime;
+ _initializePlayback(playbackTime);
} else {
throw SOURCE_NOT_ATTACHED_ERROR;
}
@@ -2126,6 +2151,16 @@ function MediaPlayer() {
streamController.load(source);
}
+ function setAlternativeVideoElement(element) {
+ if (!mediaPlayerInitialized) {
+ throw MEDIA_PLAYER_NOT_INITIALIZED_ERROR;
+ }
+
+ if (alternativeMediaController) {
+ alternativeMediaController.setAlternativeVideoElement(element);
+ }
+ }
+
/**
* Use this method to set a source URL to a valid MPD manifest file OR
* a previously downloaded and parsed manifest object. Optionally, can
@@ -2424,6 +2459,7 @@ function MediaPlayer() {
throughputController.reset();
mediaController.reset();
segmentBaseController.reset();
+ listMpdController.reset();
if (protectionController) {
if (settings.get().streaming.protection.keepProtectionMediaKeys) {
protectionController.stop();
@@ -2434,6 +2470,7 @@ function MediaPlayer() {
}
}
textController.reset();
+ alternativeMediaController.reset();
cmcdModel.reset();
cmsdModel.reset();
}
@@ -2450,6 +2487,10 @@ function MediaPlayer() {
streamController = StreamController(context).getInstance();
}
+ if (!listMpdController) {
+ listMpdController = ListMpdController(context).getInstance();
+ }
+
if (!textController) {
textController = TextController(context).create({
errHandler,
@@ -2462,6 +2503,12 @@ function MediaPlayer() {
});
}
+ listMpdController.setConfig({
+ settings: settings,
+ dashAdapter: adapter,
+ manifestLoader: manifestLoader
+ });
+
capabilitiesFilter.setConfig({
capabilities,
customParametersModel,
@@ -2559,6 +2606,7 @@ function MediaPlayer() {
cmsdModel.setConfig({});
// initializes controller
+ listMpdController.initialize();
mediaController.initialize();
throughputController.initialize();
abrController.initialize();
@@ -2566,6 +2614,7 @@ function MediaPlayer() {
textController.initialize();
gapController.initialize();
catchupController.initialize();
+ alternativeMediaController.initialize();
cmcdModel.initialize(autoPlay);
cmsdModel.initialize();
contentSteeringController.initialize();
@@ -2925,6 +2974,7 @@ function MediaPlayer() {
setProtectionData,
setRepresentationForTypeById,
setRepresentationForTypeByIndex,
+ setAlternativeVideoElement,
setTextTrack,
setVolume,
setXHRWithCredentialsForType,
diff --git a/src/streaming/MediaPlayerEvents.js b/src/streaming/MediaPlayerEvents.js
index b098926916..b681ca7107 100644
--- a/src/streaming/MediaPlayerEvents.js
+++ b/src/streaming/MediaPlayerEvents.js
@@ -156,6 +156,11 @@ class MediaPlayerEvents extends EventsBase {
* @event MediaPlayerEvents#MANIFEST_LOADED
*/
this.MANIFEST_LOADED = 'manifestLoaded';
+ /**
+ * Triggered when TBD
+ * @event MediaPlayerEvents#MANIFEST_LOADED
+ */
+ this.ALTERNATIVE_MANIFEST_LOADED = 'alternativeManifestLoaded'
/**
* Triggered anytime there is a change to the overall metrics.
diff --git a/src/streaming/constants/Constants.js b/src/streaming/constants/Constants.js
index 6922b7bb83..6bcb36f3d1 100644
--- a/src/streaming/constants/Constants.js
+++ b/src/streaming/constants/Constants.js
@@ -346,6 +346,28 @@ export default {
ABANDON_FRAGMENT_RULES: {
ABANDON_REQUEST_RULE: 'AbandonRequestsRule'
},
+ ALTERNATIVE_MPD: {
+ MODES: {
+ REPLACE: 'replace',
+ INSERT: 'insert'
+ },
+ STATUS: {
+ UPDATE: 'update',
+ REPEAT: 'repeat'
+ },
+ URIS: {
+ REPLACE: 'urn:mpeg:dash:event:alternativeMPD:replace:2025',
+ INSERT: 'urn:mpeg:dash:event:alternativeMPD:insert:2025'
+ },
+ ATTRIBUTES: {
+ NO_JUMP_DEFAULT: 1,
+ NO_JUMP_PRIORITY: 2
+ },
+ CONTENT_START: 'alternativeContentStart',
+ CONTENT_END: 'alternativeContentEnd',
+ EVENT_UPDATED: 'alternativeEventUpdated'
+
+ },
/**
* @constant {string} ID3_SCHEME_ID_URI specifies scheme ID URI for ID3 timed metadata
diff --git a/src/streaming/controllers/AlternativeMediaController.js b/src/streaming/controllers/AlternativeMediaController.js
new file mode 100644
index 0000000000..21158d2135
--- /dev/null
+++ b/src/streaming/controllers/AlternativeMediaController.js
@@ -0,0 +1,418 @@
+/**
+ * The copyright in this software is being made available under the BSD License,
+ * included below. This software may be subject to other third party and contributor
+ * rights, including patent rights, and no such rights are granted under this license.
+ *
+ * Copyright (c) 2013, Dash Industry Forum.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ * * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation and/or
+ * other materials provided with the distribution.
+ * * Neither the name of Dash Industry Forum nor the names of its
+ * contributors may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+import Events from '../../core/events/Events.js';
+import MediaPlayerEvents from '../MediaPlayerEvents.js';
+import EventBus from './../../core/EventBus.js';
+import FactoryMaker from '../../core/FactoryMaker.js';
+import Constants from '../constants/Constants.js';
+import DashConstants from '../../dash/constants/DashConstants.js';
+import MediaManager from '../MediaManager.js';
+import Debug from '../../core/Debug.js';
+
+function AlternativeMediaController() {
+ const context = this.context;
+ const eventBus = EventBus(context).getInstance();
+
+ let instance,
+ debug,
+ logger,
+ manifestInfo = {},
+ mediaManager,
+ playbackController,
+ currentEvent = null,
+ actualEventPresentationTime = 0,
+ alternativeSwitched = false,
+ timeToSwitch = 0,
+ switchTime = null,
+ calculatedMaxDuration = 0,
+ videoModel = null,
+ alternativeContext = null,
+ hideAlternativePlayerControls = false,
+ alternativeVideoElement = null;
+
+ function setConfig(config) {
+ if (!config) {
+ return;
+ }
+
+ if (config.debug) {
+ debug = config.debug;
+ }
+
+ if (config.playbackController && !playbackController) {
+ playbackController = config.playbackController;
+ }
+
+ if (config.mediaManager && !mediaManager) {
+ mediaManager = config.mediaManager;
+ }
+
+ if (config.videoModel) {
+ videoModel = config.videoModel;
+ }
+
+ if (config.alternativeContext) {
+ alternativeContext = config.alternativeContext;
+ }
+
+ if (config.hideAlternativePlayerControls) {
+ hideAlternativePlayerControls = config.hideAlternativePlayerControls;
+ }
+ }
+
+ function initialize() {
+ if (!debug) {
+ debug = Debug(context).getInstance();
+ }
+ logger = debug.getLogger(instance);
+
+ // Initialize the media manager if not already provided via config
+ if (!mediaManager) {
+ mediaManager = MediaManager(context).getInstance();
+ }
+
+ mediaManager.setConfig({
+ videoModel,
+ logger,
+ playbackController,
+ alternativeContext,
+ hideAlternativePlayerControls
+ });
+
+ mediaManager.initialize();
+
+ if (alternativeVideoElement) {
+ mediaManager.setAlternativeVideoElement(alternativeVideoElement);
+ }
+
+ // Set up event listeners
+ eventBus.on(MediaPlayerEvents.MANIFEST_LOADED, _onManifestLoaded, this);
+
+ // Listen to alternative MPD events directly from EventController
+ eventBus.on(Constants.ALTERNATIVE_MPD.URIS.REPLACE, _onAlternativeEventTriggered, this);
+ eventBus.on(Constants.ALTERNATIVE_MPD.URIS.INSERT, _onAlternativeEventTriggered, this);
+
+ // Listen to event ready to resolve for prebuffering
+ eventBus.on(Events.EVENT_READY_TO_RESOLVE, _onEventReadyToResolve, this);
+
+ // Listen to alternative event updates
+ eventBus.on(Constants.ALTERNATIVE_MPD.EVENT_UPDATED, _onAlternativeEventUpdated, this);
+ }
+
+ function _onManifestLoaded(e) {
+ const manifest = e.data
+ manifestInfo.type = manifest.type;
+ manifestInfo.originalUrl = manifest.originalUrl;
+ }
+
+ function _getAnchor(url) {
+ const regexT = /#.*?t=(\d+)(?:&|$)/;
+ const t = url.match(regexT);
+ return t ? Number(t[1]) : 0;
+ }
+
+ function _parseEvent(event) {
+ if (event.alternativeMpd) {
+ const timescale = event.eventStream.timescale || 1;
+ const alternativeMpdNode = event.alternativeMpd;
+ const mode = alternativeMpdNode.mode || Constants.ALTERNATIVE_MPD.MODES.INSERT;
+ return {
+ presentationTime: event.presentationTime / timescale,
+ duration: event.duration,
+ id: event.id,
+ schemeIdUri: event.eventStream.schemeIdUri,
+ maxDuration: alternativeMpdNode.maxDuration / timescale,
+ alternativeMPD: {
+ url: alternativeMpdNode.url,
+ },
+ mode: mode,
+ type: DashConstants.STATIC,
+ ...(alternativeMpdNode.returnOffset && { returnOffset: parseInt(alternativeMpdNode.returnOffset || '0', 10) / 1000 }),
+ ...(alternativeMpdNode.maxDuration && { clip: alternativeMpdNode.clip }),
+ ...(alternativeMpdNode.clip && { startWithOffset: alternativeMpdNode.startWithOffset }),
+ };
+ }
+ return event;
+ }
+
+ function _onAlternativeEventTriggered(e) {
+ const event = e.event;
+ try {
+ if (!event || !event.alternativeMpd) {
+ return;
+ }
+
+ // Only Alternative MPD replace events can be used for dynamic MPD
+ if (manifestInfo.type === DashConstants.DYNAMIC && event.alternativeMpd.mode === Constants.ALTERNATIVE_MPD.MODES.INSERT) {
+ logger.warn('Insert mode not supported for dynamic manifests - ignoring event');
+ return;
+ }
+
+ const parsedEvent = _parseEvent(event);
+ if (!parsedEvent) {
+ return;
+ }
+
+ // Try to prebuffer if not already done
+ mediaManager.prebufferAlternativeContent(
+ parsedEvent.id,
+ parsedEvent.alternativeMPD.url
+ );
+
+ // Set current event and timing variables
+ currentEvent = parsedEvent;
+
+ // Handle switching to alternative content (need to determine timing)
+ // This logic was previously in handleAlternativeEventTriggered
+ if (playbackController) {
+ actualEventPresentationTime = playbackController.getTime();
+ timeToSwitch = parsedEvent.startWithOffset ? actualEventPresentationTime - parsedEvent.presentationTime : 0;
+ timeToSwitch = timeToSwitch + _getAnchor(parsedEvent.alternativeMPD.url);
+ mediaManager.switchToAlternativeContent(
+ parsedEvent.id,
+ parsedEvent.alternativeMPD.url,
+ timeToSwitch
+ );
+
+ // Trigger content start event
+ if (eventBus){
+ eventBus.trigger(Constants.ALTERNATIVE_MPD.CONTENT_START, {
+ event: parsedEvent,
+ player: mediaManager.getAlternativePlayer()
+ });
+ }
+ } else {
+ throw new Error('Playback controller is not initialized');
+ }
+
+ const altPlayer = mediaManager.getAlternativePlayer();
+ if (altPlayer) {
+ altPlayer.on(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _onAlternativePlaybackTimeUpdated, this);
+ altPlayer.on(MediaPlayerEvents.DYNAMIC_TO_STATIC, _onAlternativeDynamicToStatic, this)
+ altPlayer.on(MediaPlayerEvents.PLAYBACK_ENDED, _onAlternativePlaybackEnded, this)
+ }
+ } catch (err) {
+ logger.error('Error handling alternative event:', err);
+ }
+ }
+
+ function _onEventReadyToResolve(e) {
+ const { schemeIdUri, eventId, event } = e;
+
+ try {
+ // Check if this is an alternative MPD event
+ if (schemeIdUri === Constants.ALTERNATIVE_MPD.URIS.REPLACE ||
+ schemeIdUri === Constants.ALTERNATIVE_MPD.URIS.INSERT) {
+
+ logger.info(`Event ${eventId} is ready for prebuffering`);
+
+ // Start prebuffering if we have the event data
+ if (event && event.alternativeMpd) {
+ const parsedEvent = _parseEvent(event);
+ if (parsedEvent) {
+ mediaManager.prebufferAlternativeContent(
+ parsedEvent.id,
+ parsedEvent.alternativeMPD.url
+ );
+ }
+ }
+ }
+ } catch (err) {
+ logger.error('Error handling event ready to resolve:', err);
+ }
+ }
+
+ function _onAlternativeEventUpdated(e) {
+ const { schemeIdUri, eventId, event } = e;
+
+ try {
+ if (schemeIdUri === Constants.ALTERNATIVE_MPD.URIS.REPLACE ||
+ schemeIdUri === Constants.ALTERNATIVE_MPD.URIS.INSERT) {
+ if (currentEvent && currentEvent.id === eventId) {
+ const parsedEvent = _parseEvent(event);
+ if (parsedEvent) {
+ currentEvent = parsedEvent;
+ alternativeSwitched = false;
+ logger.info(`Alternative event ${eventId} has been updated`);
+ }
+ }
+ }
+ } catch (err) {
+ logger.error('Error on alternative event update:', err);
+ }
+ }
+
+ function _onAlternativePlaybackTimeUpdated(e) {
+ try {
+ if (!currentEvent) {
+ return;
+ }
+
+ const event = { ...currentEvent };
+
+ if (event.type == DashConstants.DYNAMIC) {
+ return;
+ }
+
+ const { presentationTime, maxDuration, clip } = event;
+ if (Math.round(e.time - actualEventPresentationTime) === 0) {
+ return;
+ }
+
+ const altPlayer = mediaManager.getAlternativePlayer();
+
+ const adjustedTime = e.time - timeToSwitch;
+ if (!alternativeSwitched && adjustedTime > 0) {
+ alternativeSwitched = true;
+ switchTime = switchTime ? switchTime : adjustedTime;
+ calculatedMaxDuration = altPlayer.isDynamic() ? switchTime + maxDuration : maxDuration;
+ }
+ const shouldSwitchBack =
+ calculatedMaxDuration > 0 && (
+ // Check if the alternative content has finished playing (only for non-dynamic content)
+ (!altPlayer.isDynamic() && Math.round(altPlayer.duration() - e.time) === 0) ||
+ // Check if the alternative content reached the max duration
+ (clip && actualEventPresentationTime + adjustedTime >= presentationTime + calculatedMaxDuration) ||
+ (calculatedMaxDuration && calculatedMaxDuration <= adjustedTime)
+ );
+ if (shouldSwitchBack) {
+ _switchBackToMainContent(altPlayer, event);
+ }
+ } catch (err) {
+ logger.error(`Error at ${actualEventPresentationTime} in onAlternativePlaybackTimeUpdated:`, err);
+ }
+ }
+
+ function _onAlternativePlaybackEnded(e){
+ if (e.isLast){
+ const event = { ...currentEvent };
+ const altPlayer = mediaManager.getAlternativePlayer();
+ if (altPlayer.isDynamic()){
+ _switchBackToMainContent(altPlayer, event);
+ }
+ }
+ }
+
+ function _onAlternativeDynamicToStatic(){
+ const event = { ...currentEvent };
+ const altPlayer = mediaManager.getAlternativePlayer();
+ if (altPlayer.isDynamic()){
+ _switchBackToMainContent(altPlayer, event);
+ }
+ }
+
+ function _switchBackToMainContent(altPlayer, event) {
+ if (!event) {
+ return;
+ }
+
+ const seekTime = _calculateSeekTime(event, altPlayer);
+ mediaManager.switchBackToMainContent(seekTime);
+
+ // Trigger content end event
+ if (eventBus){
+ eventBus.trigger(Constants.ALTERNATIVE_MPD.CONTENT_END, { event });
+ }
+
+ _resetAlternativeSwitchStates();
+ }
+
+ function _calculateSeekTime(currentEvent, altPlayer) {
+ let seekTime;
+ if (currentEvent.mode === Constants.ALTERNATIVE_MPD.MODES.REPLACE) {
+ if (currentEvent.returnOffset || currentEvent.returnOffset === 0) {
+ seekTime = currentEvent.presentationTime + currentEvent.returnOffset;
+ logger.debug(`Using return offset - seeking to: ${seekTime}`);
+ } else {
+ const alternativeDuration = altPlayer.duration()
+ const alternativeEffectiveDuration = !isNaN(currentEvent.maxDuration) ? Math.min(currentEvent.maxDuration, alternativeDuration) : alternativeDuration
+ seekTime = currentEvent.presentationTime + alternativeEffectiveDuration;
+ logger.debug(`Using alternative duration - seeking to: ${seekTime}`);
+ }
+ } else if (currentEvent.mode === Constants.ALTERNATIVE_MPD.MODES.INSERT) {
+ seekTime = currentEvent.presentationTime;
+ logger.debug(`Insert mode - seeking to original presentation time: ${seekTime}`);
+ }
+ return seekTime;
+ }
+
+ function _resetAlternativeSwitchStates() {
+ currentEvent = null;
+ actualEventPresentationTime = 0;
+ timeToSwitch = 0;
+ switchTime = null;
+ alternativeSwitched = false;
+ calculatedMaxDuration = 0;
+ }
+
+ function reset() {
+
+ // Clean up alternative player event handlers before resetting media manager
+ const altPlayer = mediaManager && mediaManager.getAlternativePlayer();
+ if (altPlayer) {
+ _switchBackToMainContent(altPlayer, currentEvent);
+ altPlayer.off(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _onAlternativePlaybackTimeUpdated, this);
+ }
+
+ if (mediaManager) {
+ mediaManager.reset();
+ }
+
+ _resetAlternativeSwitchStates();
+
+ eventBus.off(MediaPlayerEvents.MANIFEST_LOADED, _onManifestLoaded, this);
+ eventBus.off(Constants.ALTERNATIVE_MPD.URIS.REPLACE, _onAlternativeEventTriggered, this);
+ eventBus.off(Constants.ALTERNATIVE_MPD.URIS.INSERT, _onAlternativeEventTriggered, this);
+ eventBus.off(Events.EVENT_READY_TO_RESOLVE, _onEventReadyToResolve, this);
+ eventBus.off(Constants.ALTERNATIVE_MPD.EVENT_UPDATED, _onAlternativeEventUpdated, this);
+ }
+
+ function setAlternativeVideoElement(element) {
+ alternativeVideoElement = element;
+ if (mediaManager) {
+ mediaManager.setAlternativeVideoElement(element);
+ }
+ }
+
+ instance = {
+ setConfig,
+ setAlternativeVideoElement,
+ initialize,
+ reset
+ };
+
+ return instance;
+}
+
+AlternativeMediaController.__dashjs_factory_name = 'AlternativeMediaController';
+const factory = FactoryMaker.getSingletonFactory(AlternativeMediaController);
+FactoryMaker.updateSingletonFactory(AlternativeMediaController.__dashjs_factory_name, factory);
+export default factory;
\ No newline at end of file
diff --git a/src/streaming/controllers/EventController.js b/src/streaming/controllers/EventController.js
index d5c39e5075..a58afda740 100644
--- a/src/streaming/controllers/EventController.js
+++ b/src/streaming/controllers/EventController.js
@@ -37,6 +37,8 @@ import XHRLoader from '../net/XHRLoader.js';
import Utils from '../../core/Utils.js';
import CommonMediaRequest from '../vo/CommonMediaRequest.js';
import CommonMediaResponse from '../vo/CommonMediaResponse.js';
+import Constants from '../constants/Constants.js';
+import Events from '../../core/events/Events.js'
function EventController() {
@@ -46,7 +48,16 @@ function EventController() {
const MPD_CALLBACK_SCHEME = 'urn:mpeg:dash:event:callback:2015';
const MPD_CALLBACK_VALUE = 1;
+ const NO_JUMP_TRIGGER_ALL = 1;
+ const NO_JUMP_TRIGGER_LAST = 2;
+
const REMAINING_EVENTS_THRESHOLD = 300;
+ const MAX_PRESENTATION_TIME_THRESHOLD = 2.0; // Maximum threshold in seconds to prevent false positives during seeks
+
+ const RETRIGGERABLES_SCHEMES = [
+ Constants.ALTERNATIVE_MPD.URIS.REPLACE,
+ Constants.ALTERNATIVE_MPD.URIS.INSERT
+ ];
const EVENT_HANDLED_STATES = {
DISCARDED: 'discarded',
@@ -109,6 +120,8 @@ function EventController() {
isStarted = false;
_onStopEventController();
}
+ eventBus.off(Events.PLAYBACK_SEEKING, _onPlaybackSeeking, instance);
+ eventBus.off(Events.PLAYBACK_SEEKED, _onPlaybackSeeked, instance);
} catch (e) {
throw e;
}
@@ -125,6 +138,9 @@ function EventController() {
if (!isStarted && !isNaN(refreshDelay)) {
isStarted = true;
eventInterval = setInterval(_onEventTimer, refreshDelay);
+ // Set up event listeners for seek operations
+ eventBus.on(Events.PLAYBACK_SEEKING, _onPlaybackSeeking, instance);
+ eventBus.on(Events.PLAYBACK_SEEKED, _onPlaybackSeeked, instance);
}
} catch (e) {
throw e;
@@ -143,6 +159,8 @@ function EventController() {
// For dynamic streams lastEventTimeCall will be large in the first iteration. Avoid firing all events at once.
presentationTimeThreshold = lastEventTimerCall > 0 ? Math.max(0, presentationTimeThreshold) : 0;
+ // If threshold is too big, it indicates a seek operation occurred, cap the threshold to prevent false positives during seeks
+ presentationTimeThreshold = presentationTimeThreshold > MAX_PRESENTATION_TIME_THRESHOLD ? 0 : presentationTimeThreshold
_triggerEvents(inbandEvents, presentationTimeThreshold, currentVideoTime);
_triggerEvents(inlineEvents, presentationTimeThreshold, currentVideoTime);
@@ -167,15 +185,39 @@ function EventController() {
*/
function _triggerEvents(events, presentationTimeThreshold, currentVideoTime) {
try {
- const callback = function (event) {
+ const callback = function (event, currentPeriodEvents) {
if (event !== undefined) {
const duration = !isNaN(event.duration) ? event.duration : 0;
- // The event is either about to start or has already been started and we are within its duration
- if ((event.calculatedPresentationTime <= currentVideoTime && event.calculatedPresentationTime + presentationTimeThreshold + duration >= currentVideoTime)) {
+ const isRetriggerable = _isRetriggerable(event);
+ const hasNoJump = _hasNoJumpValue(event);
+ const hasExecuteOnce = _hasExecuteOnceValue(event);
+
+ // Check if event is ready to resolve (earliestResolutionTimeOffset feature)
+ if (_checkEventReadyToResolve(event, currentVideoTime)) {
+ _triggerEventReadyToResolve(event);
+ }
+
+ if (isRetriggerable && _canEventRetrigger(event, currentVideoTime, presentationTimeThreshold, hasExecuteOnce)) {
+ event.triggeredStartEvent = false;
+ }
+
+ // Handle noJump events first - these ignore duration and trigger when skipping ahead
+ if (hasNoJump && _shouldTriggerNoJumpEvent(event, currentVideoTime, currentPeriodEvents)) {
+ event.triggeredNoJumpEvent = true;
_startEvent(event, MediaPlayerEvents.EVENT_MODE_ON_START);
- } else if (_eventHasExpired(currentVideoTime, duration + presentationTimeThreshold, event.calculatedPresentationTime) || _eventIsInvalid(event)) {
- logger.debug(`Removing event ${event.id} from period ${event.eventStream.period.id} as it is expired or invalid`);
- _removeEvent(events, event);
+ }
+ // Handle regular events - these check duration and timing
+ else if (event.calculatedPresentationTime <= currentVideoTime && event.calculatedPresentationTime + presentationTimeThreshold + duration >= currentVideoTime) {
+ _startEvent(event, MediaPlayerEvents.EVENT_MODE_ON_START);
+ if (hasNoJump) {
+ event.triggeredNoJumpEvent = true;
+ }
+ } else if (_eventHasExpired(currentVideoTime, duration + presentationTimeThreshold, event.calculatedPresentationTime, isRetriggerable) || _eventIsInvalid(event)) {
+ // Only remove non-retriggerables events or events with executeOnce that have been triggered
+ if (!isRetriggerable || (hasExecuteOnce && event.triggeredStartEvent)) {
+ logger.debug(`Removing event ${event.id} from period ${event.eventStream.period.id} as it is expired, invalid, or executeOnce`);
+ _removeEvent(events, event);
+ }
}
}
};
@@ -303,11 +345,34 @@ function EventController() {
return ((!value || (e.eventStream.value && e.eventStream.value === value)) && (e.id === id));
});
+ if (event.status === Constants.ALTERNATIVE_MPD.STATUS.UPDATE) {
+ if (indexOfExistingEvent !== -1) {
+ const oldEvent = events[schemeIdUri][indexOfExistingEvent];
+ event.triggeredReceivedEvent = oldEvent.triggeredReceivedEvent;
+ event.triggeredStartEvent = oldEvent.triggeredStartEvent;
+ event.triggeredReadyToResolve = oldEvent.triggeredReadyToResolve || false;
+ event.triggeredNoJumpEvent = oldEvent.triggeredNoJumpEvent || false;
+ events[schemeIdUri][indexOfExistingEvent] = event;
+ eventState = EVENT_HANDLED_STATES.UPDATED;
+
+ eventBus.trigger(Constants.ALTERNATIVE_MPD.EVENT_UPDATED, {
+ schemeIdUri: event.eventStream.schemeIdUri,
+ eventId: event.id,
+ event: event
+ })
+ } else {
+ logger.debug(`Ignoring update event with id ${id} - no existing event found`);
+ }
+ return eventState;
+ }
+
// New event, we add it to our list of events
if (indexOfExistingEvent === -1) {
events[schemeIdUri].push(event);
event.triggeredReceivedEvent = false;
event.triggeredStartEvent = false;
+ event.triggeredReadyToResolve = false;
+ event.triggeredNoJumpEvent = false;
eventState = EVENT_HANDLED_STATES.ADDED;
}
@@ -316,6 +381,8 @@ function EventController() {
const oldEvent = events[schemeIdUri][indexOfExistingEvent];
event.triggeredReceivedEvent = oldEvent.triggeredReceivedEvent;
event.triggeredStartEvent = oldEvent.triggeredStartEvent;
+ event.triggeredReadyToResolve = oldEvent.triggeredReadyToResolve || false;
+ event.triggeredNoJumpEvent = oldEvent.triggeredNoJumpEvent || false;
events[schemeIdUri][indexOfExistingEvent] = event;
eventState = EVENT_HANDLED_STATES.UPDATED;
}
@@ -398,6 +465,36 @@ function EventController() {
}
}
+ /**
+ * Handles playback seeking events to prevent false event triggers
+ * @private
+ */
+ function _onPlaybackSeeking() {
+ try {
+ // Reset the timer to current time to prevent large threshold calculations
+ const currentTime = playbackController.getTime();
+ lastEventTimerCall = currentTime;
+ logger.debug(`Seek detected, resetting lastEventTimerCall to ${currentTime}`);
+ } catch (e) {
+ logger.error(e);
+ }
+ }
+
+ /**
+ * Handles playback seeked events
+ * @private
+ */
+ function _onPlaybackSeeked() {
+ try {
+ // Ensure timer is properly reset after seek completes
+ const currentTime = playbackController.getTime();
+ lastEventTimerCall = currentTime;
+ logger.debug(`Seek completed, lastEventTimerCall reset to ${currentTime}`);
+ } catch (e) {
+ logger.error(e);
+ }
+ }
+
/**
* Iterates over the inline/inband event object and triggers a callback for each event
* @param {object} events
@@ -415,7 +512,7 @@ function EventController() {
const schemeIdEvents = currentPeriod[schemeIdUris[j]];
schemeIdEvents.forEach((event) => {
if (event !== undefined) {
- callback(event);
+ callback(event, currentPeriod);
}
});
}
@@ -426,16 +523,304 @@ function EventController() {
}
}
+ /**
+ * Auxiliary method to check for earliest resolution time events and return alternative MPD
+ * @param {object} event
+ * @return {object|null} - Returns the alternative MPD if it exists and has earliestResolutionTimeOffset, null otherwise
+ * @private
+ */
+ function _checkForEarliestResolutionTimeEvents(event) {
+ try {
+ if (!event || !event.alternativeMpd) {
+ return null;
+ }
+
+ if (event.alternativeMpd.earliestResolutionTimeOffset !== undefined) {
+ return event.alternativeMpd;
+ }
+
+ return null;
+ } catch (e) {
+ logger.error(e);
+ return null;
+ }
+ }
+
+ /**
+ * Checks if the event has an earliestResolutionTimeOffset and if it's ready to resolve
+ * @param {object} event
+ * @param {number} currentVideoTime
+ * @return {boolean}
+ * @private
+ */
+ function _checkEventReadyToResolve(event, currentVideoTime) {
+ try {
+ const earlyToResolveEvent = _checkForEarliestResolutionTimeEvents(event);
+
+ if (!earlyToResolveEvent || event.triggeredReadyToResolve) {
+ return false;
+ }
+
+ const resolutionTime = event.calculatedPresentationTime - earlyToResolveEvent.earliestResolutionTimeOffset;
+ return currentVideoTime >= resolutionTime;
+ } catch (e) {
+ logger.error(e);
+ return false;
+ }
+ }
+
+ /**
+ * Triggers the EVENT_READY_TO_RESOLVE internal event via EventBus
+ * @param {object} event
+ * @private
+ */
+ function _triggerEventReadyToResolve(event) {
+ try {
+ eventBus.trigger(Events.EVENT_READY_TO_RESOLVE, {
+ schemeIdUri: event.eventStream.schemeIdUri,
+ eventId: event.id,
+ event: event
+ });
+ event.triggeredReadyToResolve = true;
+ logger.debug(`Event ${event.id} is ready to resolve`);
+ } catch (e) {
+ logger.error(e);
+ }
+ }
+
+ /**
+ * Checks if an event is retriggerables based on its schemeIdUri
+ * @param {object} event
+ * @return {boolean}
+ * @private
+ */
+ function _isRetriggerable(event) {
+ try {
+ return RETRIGGERABLES_SCHEMES.includes(event.eventStream.schemeIdUri);
+ } catch (e) {
+ logger.error(e);
+ return false;
+ }
+ }
+
+ /**
+ * Checks if a retriggerables event can retrigger based on presentation time and duration
+ * @param {object} event
+ * @param {number} currentVideoTime
+ * @return {boolean}
+ * @private
+ */
+ function _canEventRetrigger(event, currentVideoTime, presentationTimeThreshold, executeOnce) {
+ try {
+ if (executeOnce) {
+ return false;
+ }
+ if (!event.triggeredStartEvent) {
+ return false;
+ }
+ // To avoid retrigger errors the presentationTimeThreshold must not be 0
+ if (presentationTimeThreshold === 0) {
+ return false;
+ }
+ const duration = !isNaN(event.duration) ? event.duration : 0;
+ const presentationTime = event.calculatedPresentationTime;
+ // Event can retrigger if currentTime < presentationTime OR currentTime >= presentationTime + duration
+ return currentVideoTime < presentationTime || currentVideoTime > presentationTime + presentationTimeThreshold + duration;
+ } catch (e) {
+ logger.error(e);
+ return false;
+ }
+ }
+
+ /**
+ * Checks if an event has a noJump value (1 or 2)
+ * @param {object} event
+ * @return {boolean}
+ * @private
+ */
+ function _hasNoJumpValue(event) {
+ try {
+ return event && event.alternativeMpd && (event.alternativeMpd.noJump === NO_JUMP_TRIGGER_ALL || event.alternativeMpd.noJump === NO_JUMP_TRIGGER_LAST);
+ } catch (e) {
+ logger.error(e);
+ return false;
+ }
+ }
+
+ /**
+ * Checks if an event has executeOnce value
+ * @param {object} event
+ * @return {boolean}
+ * @private
+ */
+ function _hasExecuteOnceValue(event) {
+ try {
+ return event && event.alternativeMpd && event.alternativeMpd.executeOnce === true;
+ } catch (e) {
+ logger.error(e);
+ return false;
+ }
+ }
+
+ /**
+ * Determines if a noJump event should be triggered
+ * @param {object} event
+ * @param {number} currentVideoTime
+ * @param {object} eventsInSamePeriod
+ * @return {boolean}
+ * @private
+ */
+ function _shouldTriggerNoJumpEvent(event, currentVideoTime, eventsInSamePeriod) {
+ try {
+ if (!_hasNoJumpValue(event)) {
+ return false;
+ }
+
+ // Check if the noJump attribute has already been used for this event
+ if (event.triggeredNoJumpEvent) {
+ return false;
+ }
+
+ // Check if currentVideoTime has passed the presentation time (skip ahead condition)
+ if (currentVideoTime < event.calculatedPresentationTime) {
+ return false;
+ }
+
+ if (event.alternativeMpd.noJump === NO_JUMP_TRIGGER_ALL) {
+ // noJump=1: only trigger the first event in the sequence
+ return _isFirstEventInSequence(event, eventsInSamePeriod, currentVideoTime);
+ } else if (event.alternativeMpd.noJump === NO_JUMP_TRIGGER_LAST) {
+ // noJump=2: only trigger the last event in the sequence
+ return _isLastEventInSequence(event, eventsInSamePeriod, currentVideoTime);
+ }
+
+ return false;
+ } catch (e) {
+ logger.error(e);
+ return false;
+ }
+ }
+
+ /**
+ * Determines if an event is the first one in a sequence for noJump=1 logic
+ * @param {object} event
+ * @param {object} eventsInSamePeriod
+ * @param {number} currentVideoTime
+ * @return {boolean}
+ * @private
+ */
+ function _isFirstEventInSequence(event, eventsInSamePeriod, currentVideoTime) {
+ try {
+ if (!eventsInSamePeriod || !event.eventStream) {
+ return false;
+ }
+
+ const schemeIdUri = event.eventStream.schemeIdUri;
+ const eventsWithSameScheme = eventsInSamePeriod[schemeIdUri] || [];
+
+ // Get all events with noJump=1 from the same scheme that are not in the future
+ const noJump1Events = eventsWithSameScheme.filter(e =>
+ e.alternativeMpd &&
+ e.alternativeMpd.noJump === NO_JUMP_TRIGGER_ALL &&
+ e.calculatedPresentationTime <= currentVideoTime
+ );
+
+ if (noJump1Events.length === 0) {
+ return false;
+ }
+
+ // Find the event with the lowest presentation time (the first one)
+ // While doing so, flag all subsequent events as triggered
+ const firstEvent = noJump1Events.reduce((earliest, current) => {
+ if (current.calculatedPresentationTime < earliest.calculatedPresentationTime) {
+ // Current event is earlier, so flag the previous (earliest) as triggered
+ if (!earliest.triggeredNoJumpEvent) {
+ earliest.triggeredNoJumpEvent = true;
+ }
+ return current;
+ } else {
+ // Earliest event is still the first one, so flag current as triggered
+ if (!current.triggeredNoJumpEvent) {
+ current.triggeredNoJumpEvent = true;
+ }
+ return earliest;
+ }
+ });
+ return event.id === firstEvent.id;
+ } catch (e) {
+ logger.error(e);
+ return false;
+ }
+ }
+
+ /**
+ * Determines if an event is the last one in a sequence for noJump=2 logic
+ * @param {object} event
+ * @param {object} eventsInSamePeriod
+ * @param {number} currentVideoTime
+ * @return {boolean}
+ * @private
+ */
+ function _isLastEventInSequence(event, eventsInSamePeriod, currentVideoTime) {
+ try {
+ if (!eventsInSamePeriod || !event.eventStream) {
+ return false;
+ }
+
+ const schemeIdUri = event.eventStream.schemeIdUri;
+ const eventsWithSameScheme = eventsInSamePeriod[schemeIdUri] || [];
+
+ // Get all events with noJump=2 from the same scheme that are not in the future
+ const noJump2Events = eventsWithSameScheme.filter(e =>
+ e.alternativeMpd &&
+ e.alternativeMpd.noJump === NO_JUMP_TRIGGER_LAST &&
+ e.calculatedPresentationTime <= currentVideoTime
+ );
+
+ if (noJump2Events.length === 0) {
+ return false;
+ }
+
+ // Find the event with the highest presentation time (the last one)
+ // While doing so, flag all previous events as triggered
+ const lastEvent = noJump2Events.reduce((latest, current) => {
+ if (current.calculatedPresentationTime > latest.calculatedPresentationTime) {
+ // Current event is later, so flag the previous (latest) as triggered
+ if (!latest.triggeredNoJumpEvent) {
+ latest.triggeredNoJumpEvent = true;
+ }
+ return current;
+ } else {
+ // Latest event is still the last one, so flag current as triggered
+ if (!current.triggeredNoJumpEvent) {
+ current.triggeredNoJumpEvent = true;
+ }
+ return latest;
+ }
+ });
+ return event.id === lastEvent.id;
+ } catch (e) {
+ logger.error(e);
+ return false;
+ }
+ }
+
+
/**
* Checks if an event is expired. For instance if the presentationTime + the duration of an event are smaller than the current video time.
* @param {number} currentVideoTime
* @param {number} threshold
* @param {number} calculatedPresentationTimeInSeconds
+ * @param {boolean} isRetriggerable
* @return {boolean}
* @private
*/
- function _eventHasExpired(currentVideoTime, threshold, calculatedPresentationTimeInSeconds) {
+ function _eventHasExpired(currentVideoTime, threshold, calculatedPresentationTimeInSeconds, isRetriggerable = false) {
try {
+ // Retriggerables events don't expire in the traditional sense
+ if (isRetriggerable) {
+ return false;
+ }
return currentVideoTime - threshold > calculatedPresentationTimeInSeconds;
} catch (e) {
logger.error(e);
@@ -480,7 +865,6 @@ function EventController() {
eventBus.trigger(event.eventStream.schemeIdUri, { event }, { mode });
return;
}
-
if (!event.triggeredStartEvent) {
if (event.eventStream.schemeIdUri === MPD_RELOAD_SCHEME && event.eventStream.value == MPD_RELOAD_VALUE) {
//If both are set to zero, it indicates the media is over at this point. Don't reload the manifest.
diff --git a/src/streaming/controllers/ExtUrlQueryInfoController.js b/src/streaming/controllers/ExtUrlQueryInfoController.js
index a1c39b80ba..1a89fa859e 100644
--- a/src/streaming/controllers/ExtUrlQueryInfoController.js
+++ b/src/streaming/controllers/ExtUrlQueryInfoController.js
@@ -133,20 +133,22 @@ function ExtUrlQueryInfoController() {
};
_generateQueryParams(periodObject, period, mpdUrlQuery, mpdQueryStringInformation, DashConstants.PERIOD);
- period.AdaptationSet.forEach((adaptationSet) => {
- const adaptationObject = {
- representation: []
- };
- _generateQueryParams(adaptationObject, adaptationSet, mpdUrlQuery, periodObject, DashConstants.ADAPTATION_SET);
-
- adaptationSet.Representation.forEach((representation) => {
- const representationObject = {};
- _generateQueryParams(representationObject, representation, mpdUrlQuery, adaptationObject, DashConstants.REPRESENTATION);
-
- adaptationObject.representation.push(representationObject);
+ if (!period.ImportedMPD) {
+ period.AdaptationSet.forEach((adaptationSet) => {
+ const adaptationObject = {
+ representation: []
+ };
+ _generateQueryParams(adaptationObject, adaptationSet, mpdUrlQuery, periodObject, DashConstants.ADAPTATION_SET);
+
+ adaptationSet.Representation.forEach((representation) => {
+ const representationObject = {};
+ _generateQueryParams(representationObject, representation, mpdUrlQuery, adaptationObject, DashConstants.REPRESENTATION);
+
+ adaptationObject.representation.push(representationObject);
+ });
+ periodObject.adaptation.push(adaptationObject);
});
- periodObject.adaptation.push(adaptationObject);
- });
+ }
mpdQueryStringInformation.period.push(periodObject);
});
}
diff --git a/src/streaming/controllers/ListMpdController.js b/src/streaming/controllers/ListMpdController.js
new file mode 100644
index 0000000000..faefb46301
--- /dev/null
+++ b/src/streaming/controllers/ListMpdController.js
@@ -0,0 +1,186 @@
+/**
+ * The copyright in this software is being made available under the BSD License,
+ * included below. This software may be subject to other third party and contributor
+ * rights, including patent rights, and no such rights are granted under this license.
+ *
+ * Copyright (c) 2013, Dash Industry Forum.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ * * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation and/or
+ * other materials provided with the distribution.
+ * * Neither the name of Dash Industry Forum nor the names of its
+ * contributors may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+import EventBus from '../../core/EventBus.js';
+import Events from '../../core/events/Events.js';
+import MediaPlayerEvents from '../MediaPlayerEvents.js';
+import FactoryMaker from '../../core/FactoryMaker.js';
+import DashConstants from '../../dash/constants/DashConstants.js';
+
+const DEFAULT_EARLIEST_RESOLUTION_TIME_OFFSET = 60;
+
+function ListMpdController() {
+
+ let context = this.context;
+ let eventBus = EventBus(context).getInstance();
+
+ let instance,
+ settings,
+ linkedPeriodList,
+ dashAdapter,
+ currentManifest,
+ manifestLoader,
+ mpdHasDuration
+
+ function setConfig(config) {
+ if (!config) {
+ return;
+ }
+
+ if (config.settings) {
+ settings = config.settings;
+ }
+
+ if (config.dashAdapter) {
+ dashAdapter = config.dashAdapter;
+ }
+
+ if (config.manifestLoader) {
+ manifestLoader = config.manifestLoader;
+ }
+ }
+
+ function setup() {
+ resetInitialSettings();
+ }
+
+ function initialize() {
+ eventBus.on(Events.LIST_MPD_FOUND, _onListMpdFound, instance);
+ eventBus.on(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _triggerImportMpd, instance);
+ }
+
+ function _importMpdInLinkedPeriod(time) {
+ linkedPeriodList.forEach(linkedPeriod => {
+ if (_shouldImportMpd(linkedPeriod, time)) {
+ loadImportedMpd(currentManifest, linkedPeriod);
+ }
+ });
+ }
+
+ function _onListMpdFound({ manifest }) {
+ currentManifest = manifest;
+ linkedPeriodList = dashAdapter.getLinkedPeriods(manifest);
+
+ manifest.Period[0].start = manifest.Period[0].start ?? 0;
+ if (manifest.Period[0].start !== 0) {
+ throw new Error('The first period in a list MPD must have start time equal to 0');
+ }
+
+ mpdHasDuration = manifest.hasOwnProperty(DashConstants.MEDIA_PRESENTATION_DURATION);
+ if (!mpdHasDuration) {
+ manifest.mediaPresentationDuration = 0;
+ for (let i = manifest.Period.length - 1; i >= 0; i--) {
+ manifest.mediaPresentationDuration += manifest.Period[i].duration;
+ if (manifest.Period[i].start) {
+ manifest.mediaPresentationDuration += manifest.Period[i].start;
+ break;
+ }
+ }
+ }
+
+ const startPeriod = linkedPeriodList.find(period => period.start === 0);
+ if (startPeriod) {
+ loadImportedMpd(manifest, startPeriod);
+ } else {
+ eventBus.trigger(Events.MANIFEST_UPDATED, { manifest: manifest });
+ }
+ }
+
+ function loadImportedMpd(manifest, period) {
+ const relativePath = period.ImportedMPD.uri;
+ const baseUrl = period.BaseURL ?? manifest.BaseURL;
+ const resolvedUri = baseUrl ? baseUrl[0].__text + relativePath : relativePath;
+
+ const updatedManifest = new Promise(resolve => {
+ manifestLoader.load(resolvedUri, null, null, true)
+ .then((importedManifest) => {
+ dashAdapter.mergeManifests(manifest, importedManifest, period, mpdHasDuration);
+ }, () => {
+ dashAdapter.mergeManifests(manifest, null, period, mpdHasDuration);
+ })
+ .then(() => {
+ eventBus.trigger(Events.MANIFEST_UPDATED, { manifest });
+ linkedPeriodList = linkedPeriodList.filter((element) => element.id !== period.id);
+ resolve(manifest);
+ });
+ });
+ return updatedManifest;
+ }
+
+ function _triggerImportMpd(e) {
+ if (!linkedPeriodList || !linkedPeriodList.length) {
+ return;
+ }
+
+ _importMpdInLinkedPeriod(e.time);
+ }
+
+ function _shouldImportMpd(linkedPeriod, time) {
+ if (!linkedPeriod.ImportedMPD) {
+ return false
+ }
+
+ // This setting Resolves a conflict with GapController in low-bandwidth streams by preventing playback stalls during buffering gaps.
+ const { minEarliestResolutionTimeOffset } = settings.get().streaming.listMpd;
+ const earliestResolutionTimeOffset = linkedPeriod.ImportedMPD.earliestResolutionTimeOffset ?? DEFAULT_EARLIEST_RESOLUTION_TIME_OFFSET;
+ const resolutionTime = Math.max(earliestResolutionTimeOffset, minEarliestResolutionTimeOffset);
+ const { start } = dashAdapter.getPeriodById(linkedPeriod.id);
+
+ return time >= start - resolutionTime;
+ }
+
+ function resetInitialSettings() {
+ linkedPeriodList = [];
+ currentManifest = null;
+ mpdHasDuration = false;
+ }
+
+ function reset() {
+ resetInitialSettings();
+ eventBus.off(Events.LIST_MPD_FOUND, _onListMpdFound, instance);
+ eventBus.off(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _triggerImportMpd, instance);
+ }
+
+ instance = {
+ initialize,
+ loadImportedMpd,
+ reset,
+ setConfig
+ };
+
+ setup();
+
+ return instance;
+}
+
+ListMpdController.__dashjs_factory_name = 'ListMpdController';
+const factory = FactoryMaker.getSingletonFactory(ListMpdController);
+FactoryMaker.updateSingletonFactory(ListMpdController.__dashjs_factory_name, factory);
+export default factory;
diff --git a/src/streaming/controllers/PlaybackController.js b/src/streaming/controllers/PlaybackController.js
index 3c04e0e4db..c71793eaed 100644
--- a/src/streaming/controllers/PlaybackController.js
+++ b/src/streaming/controllers/PlaybackController.js
@@ -266,6 +266,25 @@ function PlaybackController() {
seek(seektime, stickToBuffered, internal, adjustLiveDelay);
}
+ function seekToStartDvrWindow(stickToBuffered = false, internal = false, adjustLiveDelay = false) {
+ const dvrWindowStart = getDvrWindowStart();
+
+ if (dvrWindowStart === 0) {
+ return;
+ }
+
+ seek(dvrWindowStart, stickToBuffered, internal, adjustLiveDelay);
+ }
+
+ function getDvrWindowStart() {
+ if (!streamInfo || !videoModel || !isDynamic) {
+ return;
+ }
+ const type = streamController && streamController.hasVideoTrack() ? Constants.VIDEO : Constants.AUDIO;
+ const dvrInfo = dashMetrics.getCurrentDVRInfo(type);
+ return dvrInfo && dvrInfo.range ? dvrInfo.range.start : 0;
+ }
+
function _getDvrWindowEnd() {
if (!streamInfo || !videoModel || !isDynamic) {
return;
@@ -704,7 +723,9 @@ function PlaybackController() {
}
function _onPlaybackProgress() {
- eventBus.trigger(Events.PLAYBACK_PROGRESS, { streamId: streamInfo.id });
+ if (streamInfo){
+ eventBus.trigger(Events.PLAYBACK_PROGRESS, { streamId: streamInfo.id });
+ }
}
function _onPlaybackRateChanged() {
@@ -920,6 +941,7 @@ function PlaybackController() {
getAvailabilityStartTime,
getBufferLevel,
getCurrentLiveLatency,
+ getDvrWindowStart,
getEnded,
getInitialCatchupModeActivated,
getIsDynamic,
@@ -945,6 +967,7 @@ function PlaybackController() {
seek,
seekToCurrentLive,
seekToOriginalLive,
+ seekToStartDvrWindow,
setConfig,
updateCurrentTime,
};
diff --git a/src/streaming/controllers/StreamController.js b/src/streaming/controllers/StreamController.js
index fcfdf80342..a971587203 100644
--- a/src/streaming/controllers/StreamController.js
+++ b/src/streaming/controllers/StreamController.js
@@ -48,6 +48,7 @@ import ConformanceViolationConstants from '../constants/ConformanceViolationCons
import ExtUrlQueryInfoController from './ExtUrlQueryInfoController.js';
import ProtectionEvents from '../protection/ProtectionEvents.js';
import ProtectionErrors from '../protection/errors/ProtectionErrors.js';
+import ListMpdController from './ListMpdController.js';
const PLAYBACK_ENDED_TIMER_INTERVAL = 200;
const DVR_WAITING_OFFSET = 2;
@@ -61,7 +62,7 @@ function StreamController() {
dashMetrics, mediaSourceController, timeSyncController, contentSteeringController, baseURLController,
segmentBaseController, uriFragmentModel, abrController, throughputController, mediaController, eventController,
initCache, errHandler, timelineConverter, streams, activeStream, protectionController, textController,
- protectionData, extUrlQueryInfoController,
+ protectionData, extUrlQueryInfoController, listMpdController,
autoPlay, isStreamSwitchingInProgress, hasMediaError, hasInitialisationError, mediaSource, videoModel,
playbackController, serviceDescriptionController, mediaPlayerModel, customParametersModel, isPaused,
initialPlayback, initialSteeringRequest, playbackEndedTimerInterval, preloadingStreams, settings,
@@ -101,6 +102,7 @@ function StreamController() {
eventController.start();
extUrlQueryInfoController = ExtUrlQueryInfoController(context).getInstance();
+ listMpdController = ListMpdController(context).getInstance();
timeSyncController.setConfig({
dashMetrics, baseURLController, errHandler, settings
@@ -661,11 +663,25 @@ function StreamController() {
});
Promise.all(promises)
+ .then(() => {
+ const periodId = seekToStream.getId();
+ if (!periodId) {
+ throw new Error('Stream does not have a valid period id');
+ }
+ const seekToPeriod = manifestModel.getValue().Period.find((periodInfo) => periodInfo.id === periodId);
+ if (seekToPeriod.ImportedMPD) {
+ return listMpdController
+ .loadImportedMpd(manifestModel.getValue(), seekToPeriod)
+ .then(updatedManifest => {
+ baseURLController.update(updatedManifest);
+ });
+ }
+ })
.then(() => {
_switchStream(seekToStream, activeStream, seekTime);
})
- .catch((e) => {
- errHandler.error(e);
+ .catch(error => {
+ errHandler.error(error);
});
}
@@ -936,7 +952,10 @@ function StreamController() {
const previousStream = i === 0 ? activeStream : upcomingStreams[i - 1];
// If the preloading for the current stream is not scheduled, but its predecessor has finished buffering we can start prebuffering this stream
- if (!stream.getPreloaded() && previousStream.getHasFinishedBuffering()) {
+ const periodId = stream.getId()
+ const linkedPeriod = manifestModel.getValue().Period.find((periodInfo => periodInfo.id === periodId));
+ const isLinkedPeriod = linkedPeriod && linkedPeriod.ImportedMPD;
+ if (!stream.getPreloaded() && previousStream.getHasFinishedBuffering() && !isLinkedPeriod) {
_onStreamCanLoadNext(stream, previousStream);
}
i += 1;
@@ -1021,8 +1040,12 @@ function StreamController() {
activeStream.setIsEndedEventSignaled(true);
const nextStream = _getNextStream();
if (nextStream) {
- logger.debug(`StreamController onEnded, found next stream with id ${nextStream.getStreamInfo().id}. Switching from ${activeStream.getStreamInfo().id} to ${nextStream.getStreamInfo().id}`);
- _switchStream(nextStream, activeStream, NaN);
+ const streamId = nextStream.getStreamInfo().id;
+ logger.debug(`StreamController onEnded, found next stream with id ${streamId}. Switching from ${activeStream.getStreamInfo().id} to ${nextStream.getStreamInfo().id}`);
+ const nextPeriod = manifestModel.getValue().Period.find((periodInfo) => periodInfo.id == streamId);
+ if (!nextPeriod.ImportedMPD) {
+ _switchStream(nextStream, activeStream, NaN);
+ }
} else {
logger.debug('StreamController no next stream found');
activeStream.setIsEndedEventSignaled(false);
diff --git a/src/streaming/utils/CapabilitiesFilter.js b/src/streaming/utils/CapabilitiesFilter.js
index 65711522c4..26eea689cc 100644
--- a/src/streaming/utils/CapabilitiesFilter.js
+++ b/src/streaming/utils/CapabilitiesFilter.js
@@ -79,7 +79,7 @@ function CapabilitiesFilter() {
_removeMultiRepresentationPreselections(manifest);
_removePreselectionWithNoAdaptationSet(manifest);
-
+
return _applyCustomFilters(manifest);
})
.then(() => {
@@ -205,19 +205,21 @@ function CapabilitiesFilter() {
const configurations = [];
manifest.Period.forEach((period) => {
- period.AdaptationSet.forEach((as) => {
- if (adapter.getIsTypeOf(as, type)) {
- as.Representation.forEach((rep, i) => {
- const codec = adapter.getCodec(as, i, false);
- _processCodecToCheck(type, rep, codec, configurationsSet, configurations);
-
- const supplementalCodecs = adapter.getSupplementalCodecs(rep)
- if (supplementalCodecs.length > 0) {
- _processCodecToCheck(type, rep, supplementalCodecs[0], configurationsSet, configurations);
- }
- });
- }
- });
+ if (!period.ImportedMPD) {
+ period.AdaptationSet.forEach((as) => {
+ if (adapter.getIsTypeOf(as, type)) {
+ as.Representation.forEach((rep, i) => {
+ const codec = adapter.getCodec(as, i, false);
+ _processCodecToCheck(type, rep, codec, configurationsSet, configurations);
+
+ const supplementalCodecs = adapter.getSupplementalCodecs(rep)
+ if (supplementalCodecs.length > 0) {
+ _processCodecToCheck(type, rep, supplementalCodecs[0], configurationsSet, configurations);
+ }
+ });
+ }
+ });
+ }
if (period.Preselection && period.Preselection.length) {
period.Preselection.forEach((prsl) => {
if (adapter.getPreselectionIsTypeOf(prsl, period.AdaptationSet, type)) {
@@ -305,12 +307,12 @@ function CapabilitiesFilter() {
if (primaryElement.tagName === DashConstants.PRESELECTION && prslCommonRep) {
let prslCommonRepresentationHDRColorimetryConfig = _convertHDRColorimetryToConfig(prslCommonRep);
-
+
// if either the properties of the Preselection or the CommonRepresentation is not supported, we can't mark the config as supported.
let isCommonRepCfgSupported = prslCommonRepresentationHDRColorimetryConfig.isSupported;
delete prslCommonRepresentationHDRColorimetryConfig.isSupported;
config.isSupported = config.isSupported && isCommonRepCfgSupported;
-
+
// asign only those attributes that are not present in config
_assignMissing(config, prslCommonRepresentationHDRColorimetryConfig);
}
@@ -322,7 +324,7 @@ function CapabilitiesFilter() {
if (primaryElement.tagName === DashConstants.PRESELECTION && prslCommonRep) {
let prslCommonRepresentationHDRMetadataFormatConfig = _convertHDRMetadataFormatToConfig(prslCommonRep);
-
+
// if either the properties of the Preselection or the CommonRepresentation is not supported, we can't mark the config as supported.
let isCommonRepCfgSupported = prslCommonRepresentationHDRMetadataFormatConfig.isSupported;
delete prslCommonRepresentationHDRMetadataFormatConfig.isSupported;
@@ -463,26 +465,28 @@ function CapabilitiesFilter() {
}
manifest.Period.forEach((period) => {
- period.AdaptationSet = period.AdaptationSet.filter((as) => {
+ if (!period.ImportedMPD) {
+ period.AdaptationSet = period.AdaptationSet.filter((as) => {
- if (!as.Representation || as.Representation.length === 0) {
- return true;
- }
+ if (!as.Representation || as.Representation.length === 0) {
+ return true;
+ }
- const adaptationSetEssentialProperties = adapter.getEssentialProperties(as);
- const doesSupportEssentialProperties = _doesSupportEssentialProperties(adaptationSetEssentialProperties);
+ const adaptationSetEssentialProperties = adapter.getEssentialProperties(as);
+ const doesSupportEssentialProperties = _doesSupportEssentialProperties(adaptationSetEssentialProperties);
- if (!doesSupportEssentialProperties) {
- return false;
- }
+ if (!doesSupportEssentialProperties) {
+ return false;
+ }
- as.Representation = as.Representation.filter((rep) => {
- const essentialProperties = adapter.getEssentialProperties(rep);
- return _doesSupportEssentialProperties(essentialProperties);
- });
+ as.Representation = as.Representation.filter((rep) => {
+ const essentialProperties = adapter.getEssentialProperties(rep);
+ return _doesSupportEssentialProperties(essentialProperties);
+ });
- return as.Representation && as.Representation.length > 0;
- });
+ return as.Representation && as.Representation.length > 0;
+ });
+ }
if (period.Preselection && period.Preselection.length) {
period.Preselection = period.Preselection.filter(prsl => {
diff --git a/test/functional/adapter/DashJsAdapter.js b/test/functional/adapter/DashJsAdapter.js
index c1cc2c6949..e47327c02e 100644
--- a/test/functional/adapter/DashJsAdapter.js
+++ b/test/functional/adapter/DashJsAdapter.js
@@ -8,6 +8,7 @@ class DashJsAdapter {
constructor() {
this.player = null;
this.videoElement = document.getElementById('video-element');
+ this.alternativeVideoElement = document.getElementById('alternative-video-element');
this.ttmlRenderingDiv = document.getElementById('ttml-rendering-div');
this.startedFragmentDownloads = [];
this.logEvents = {};
@@ -44,6 +45,15 @@ class DashJsAdapter {
})
}
+ initForAlternativeMedia(mpd) {
+ this._initLogEvents();
+ this._createPlayerInstance();
+
+ this.player.initialize(this.videoElement, mpd, true);
+ this.player.setAlternativeVideoElement(this.alternativeVideoElement);
+ this._registerInternalEvents();
+ }
+
_initLogEvents() {
this.logEvents[Debug.LOG_LEVEL_NONE] = [];
this.logEvents[Debug.LOG_LEVEL_FATAL] = [];
diff --git a/test/functional/config/test-configurations/streams/alternative-mpd.json b/test/functional/config/test-configurations/streams/alternative-mpd.json
new file mode 100644
index 0000000000..4ba8a08279
--- /dev/null
+++ b/test/functional/config/test-configurations/streams/alternative-mpd.json
@@ -0,0 +1,123 @@
+{
+ "customLaunchers": {
+ "chrome_custom": {
+ "base": "Chrome",
+ "flags": [
+ "--disable-web-security",
+ "--autoplay-policy=no-user-gesture-required",
+ "--disable-popup-blocking",
+ "--disable-search-engine-choice-screen",
+ "--allow-running-insecure-content",
+ "--disable-features=VizDisplayCompositor"
+ ]
+ }
+ },
+ "testfiles": {
+ "included": [
+ "feature-support/alternative/alternative-mpd-replace-vod",
+ "feature-support/alternative/alternative-mpd-insert-vod",
+ "feature-support/alternative/alternative-mpd-replace-live",
+ "feature-support/alternative/alternative-mpd-executeOnce",
+ "feature-support/alternative/alternative-mpd-clip-vod",
+ "feature-support/alternative/alternative-mpd-clip-live",
+ "feature-support/alternative/alternative-mpd-status-update-live",
+ "feature-support/alternative/alternative-mpd-returnOffset"
+ ],
+ "excluded": []
+ },
+ "testvectors": [
+ {
+ "name": "Alternative MPD Replace - VOD to VOD Test",
+ "type": "vod",
+ "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-replace-vod"
+ ]
+ },
+ {
+ "name": "Alternative MPD Replace - VOD to LIVE Test",
+ "type": "vod",
+ "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-replace-vod"
+ ]
+ },
+ {
+ "name": "Alternative MPD Insert - VOD to VOD Test",
+ "type": "vod",
+ "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-insert-vod"
+ ]
+ },
+ {
+ "name": "Alternative MPD Insert - VOD to LIVE Test",
+ "type": "vod",
+ "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-insert-vod"
+ ]
+ },
+ {
+ "name": "Alternative MPD Replace - Live to VOD Test",
+ "type": "live",
+ "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd",
+ "alternativeUrl": "https://dash.akamaized.net/dashif/ad-insertion-testcase1/batch2/real/b/ad-insertion-testcase1.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-replace-live"
+ ]
+ },
+ {
+ "name": "Alternative MPD Replace - Live to Live Test",
+ "type": "live",
+ "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd",
+ "alternativeUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-replace-live"
+ ]
+ },
+ {
+ "name": "Alternative MPD Execute Once - VOD to VOD Test",
+ "type": "vod",
+ "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-executeOnce.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-executeOnce"
+ ]
+ },
+ {
+ "name": "Alternative MPD Clip - VOD to VOD Test",
+ "type": "vod",
+ "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-clip.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-clip-vod"
+ ]
+ },
+ {
+ "name": "Alternative MPD Clip - Live to Live Test",
+ "type": "live",
+ "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd",
+ "alternativeUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-clip-live"
+ ]
+ },
+ {
+ "name": "Alternative MPD Status Update - Live to Live Test",
+ "type": "live",
+ "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd",
+ "alternativeUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-status-update-live"
+ ]
+ },
+ {
+ "name": "Alternative MPD returnOffset - Live to Live Test",
+ "type": "live",
+ "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd",
+ "alternativeUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd",
+ "includedTestfiles": [
+ "feature-support/alternative/alternative-mpd-returnOffset"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/test/functional/content/alternative-mpd/alternative-mpd-clip.mpd b/test/functional/content/alternative-mpd/alternative-mpd-clip.mpd
new file mode 100644
index 0000000000..de2069352c
--- /dev/null
+++ b/test/functional/content/alternative-mpd/alternative-mpd-clip.mpd
@@ -0,0 +1,22 @@
+
+https://dash.akamaized.net/akamai/bbb_30fps/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/functional/content/alternative-mpd/alternative-mpd-executeOnce.mpd b/test/functional/content/alternative-mpd/alternative-mpd-executeOnce.mpd
new file mode 100644
index 0000000000..6626a35239
--- /dev/null
+++ b/test/functional/content/alternative-mpd/alternative-mpd-executeOnce.mpd
@@ -0,0 +1,22 @@
+
+https://dash.akamaized.net/akamai/bbb_30fps/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd b/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd
new file mode 100644
index 0000000000..ec0c697a4c
--- /dev/null
+++ b/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd
@@ -0,0 +1,22 @@
+
+https://dash.akamaized.net/akamai/bbb_30fps/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd b/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd
new file mode 100644
index 0000000000..aff17eda2c
--- /dev/null
+++ b/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd
@@ -0,0 +1,22 @@
+
+https://dash.akamaized.net/akamai/bbb_30fps/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd b/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd
new file mode 100644
index 0000000000..b958a82be9
--- /dev/null
+++ b/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd
@@ -0,0 +1,22 @@
+
+https://dash.akamaized.net/akamai/bbb_30fps/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd b/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd
new file mode 100644
index 0000000000..a684c18ef8
--- /dev/null
+++ b/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd
@@ -0,0 +1,22 @@
+
+https://dash.akamaized.net/akamai/bbb_30fps/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/functional/test/common/common.js b/test/functional/test/common/common.js
index ba1e60e08c..68fa46b29d 100644
--- a/test/functional/test/common/common.js
+++ b/test/functional/test/common/common.js
@@ -106,6 +106,18 @@ export function initializeDashJsAdapterForPreload(item, mpd, settings) {
return playerAdapter
}
+export function initializeDashJsAdapterForAlternativMedia(item, mpd, settings) {
+ let playerAdapter = new DashJsAdapter();
+ playerAdapter.initForAlternativeMedia(mpd);
+ playerAdapter.setDrmData(item.drm);
+ if (settings) {
+ playerAdapter.updateSettings(settings);
+ }
+
+ playerAdapter.attachSource(mpd);
+ return playerAdapter
+}
+
export function playForDuration(durationInMilliseconds) {
return new Promise(resolve => setTimeout(resolve, durationInMilliseconds));
}
diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-clip-live.js b/test/functional/test/feature-support/alternative/alternative-mpd-clip-live.js
new file mode 100644
index 0000000000..dab4d07750
--- /dev/null
+++ b/test/functional/test/feature-support/alternative/alternative-mpd-clip-live.js
@@ -0,0 +1,140 @@
+import Constants from '../../../../../src/streaming/constants/Constants.js';
+import Utils from '../../../src/Utils.js';
+import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js';
+import { expect } from 'chai';
+
+/**
+ * Utility function to modify a live manifest by injecting Alternative MPD events with clip functionality
+ * This tests the clip feature scenarios where only a portion of the alternative live content is played
+ */
+function injectAlternativeMpdClipEvents(player, originalManifestUrl, alternativeManifestUrl, presentationTime, maxDuration, callback) {
+ const mediaPlayer = player.player;
+
+ mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => {
+ manifest.Period[0].EventStream = [];
+
+ const duration = 8000;
+ const earliestResolutionTimeOffset = 3000;
+
+ const replaceClipEvent = {
+ schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025',
+ timescale: 1000,
+ Event: [{
+ id: 1,
+ presentationTime: presentationTime,
+ duration: duration,
+ ReplacePresentation: {
+ url: alternativeManifestUrl,
+ earliestResolutionTimeOffset: earliestResolutionTimeOffset,
+ maxDuration: maxDuration,
+ clip: 'true',
+ }
+ }]
+ };
+
+ manifest.Period[0].EventStream.push(replaceClipEvent);
+ mediaPlayer.attachSource(manifest);
+
+ if (callback) {
+ callback();
+ }
+ });
+}
+
+Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-clip-live').forEach((item) => {
+ const name = item.name;
+ const originalUrl = item.originalUrl;
+ const alternativeUrl = item.alternativeUrl;
+
+ describe(`Alternative MPD Replace with Clip functionality tests for Live-to-Live: ${name}`, () => {
+
+ let player;
+ let presentationTime;
+ let maxDuration;
+ let presentationTimeOffset;
+
+ before((done) => {
+ const currentPresentationTime = Date.now();
+ presentationTimeOffset = 10000 //includes potential latency
+ presentationTime = currentPresentationTime - presentationTimeOffset; //alternative content already started
+ maxDuration = 10000;
+
+ // Initialize the player without attaching source immediately
+ player = initializeDashJsAdapterForAlternativMedia(item, null);
+
+ // Use the utility function to inject Alternative MPD events with clip for live-to-live
+ injectAlternativeMpdClipEvents(player, originalUrl, alternativeUrl, presentationTime, maxDuration, () => {
+ done();
+ });
+ });
+
+ after(() => {
+ if (player) {
+ player.destroy();
+ }
+ });
+
+ it('should play live content, switch to clipped alternative live content, then back to original live content', (done) => {
+ let alternativeContentDetected = false;
+ let backToOriginalDetected = false;
+ let eventTriggered = false;
+ let alternativeEndTime = 0;
+ let alternativeStartTime = 0;
+ let expectedMaxDuration = 0;
+ let expectedPresentationTime = 0;
+
+ const timeout = setTimeout(() => {
+ done(new Error('Test timed out - alternative MPD replace clip event not completed within 35 seconds'));
+ }, 35000);
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => {
+ eventTriggered = true;
+ });
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => {
+ if (data.event.mode === 'replace') {
+ alternativeContentDetected = true;
+ alternativeStartTime = Date.now() / 1000;
+ expectedMaxDuration = data.event.maxDuration;
+ expectedPresentationTime = data.event.presentationTime;
+ expect(data.event.clip).to.be.true;
+ }
+ });
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => {
+ if (data.event.mode === 'replace') {
+ backToOriginalDetected = true;
+ alternativeEndTime = Date.now() / 1000;
+ const actualTerminationTime = player.player.timeAsUTC();
+ const expectedTerminationTime = (expectedPresentationTime + expectedMaxDuration);
+ clearTimeout(timeout);
+
+ // Wait to ensure stability
+ setTimeout(() => {
+ expect(eventTriggered).to.be.true;
+ expect(alternativeContentDetected).to.be.true;
+ expect(backToOriginalDetected).to.be.true;
+
+ // Verify that the actual duration of alternative content is less than maxDuration
+ const actualAlternativeDuration = (alternativeEndTime - alternativeStartTime);
+ expect(actualAlternativeDuration).to.be.lessThan(expectedMaxDuration);
+
+ // The alternative content should terminate at approximately PRT + APDmax
+ // Allow tolerance for live content timing variations
+ expect(actualTerminationTime).to.be.at.closeTo(expectedTerminationTime, 2);
+
+ done();
+ }, 2000); // Longer wait for live content stability
+ }
+ });
+
+ // Handle errors
+ player.registerEvent('error', (e) => {
+ clearTimeout(timeout);
+ done(new Error(`Player error: ${JSON.stringify(e)}`));
+ });
+
+ }, 45000);
+
+ });
+});
\ No newline at end of file
diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-clip-vod.js b/test/functional/test/feature-support/alternative/alternative-mpd-clip-vod.js
new file mode 100644
index 0000000000..27b9146bcb
--- /dev/null
+++ b/test/functional/test/feature-support/alternative/alternative-mpd-clip-vod.js
@@ -0,0 +1,93 @@
+import Constants from '../../../../../src/streaming/constants/Constants.js';
+import Utils from '../../../src/Utils.js';
+import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js';
+import { expect } from 'chai';
+
+Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-clip-vod').forEach((item) => {
+ const name = item.name;
+ const url = item.url;
+
+ describe(`Alternative MPD Clip functionality tests for VOD-to-VOD: ${name}`, () => {
+
+ let player;
+
+ before(() => {
+ player = initializeDashJsAdapterForAlternativMedia(item, url);
+ });
+
+ after(() => {
+ if (player) {
+ player.destroy();
+ }
+ });
+
+ it('should play VOD content, seek forward to simulate delay, then test clip behavior with alternative VOD content', (done) => {
+ let alternativeContentDetected = false;
+ let backToOriginalDetected = false;
+ let eventTriggered = false;
+ let alternativeEndTime = 0;
+ let expectedMaxDuration = 0;
+ let expectedPresentationTime = 0;
+ let seekPerformed = false;
+
+ const timeout = setTimeout(() => {
+ done(new Error('Test timed out - alternative MPD replace clip event not completed within 30 seconds'));
+ }, 30000);
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => {
+ eventTriggered = true;
+ });
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => {
+ if (data.event.mode === 'replace') {
+ alternativeContentDetected = true;
+ expectedMaxDuration = data.event.maxDuration;
+ expectedPresentationTime = data.event.presentationTime;
+
+ expect(data.event.clip).to.be.true;
+ }
+ });
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => {
+ if (data.event.mode === 'replace') {
+ alternativeEndTime = player.getCurrentTime();
+ backToOriginalDetected = true;
+ clearTimeout(timeout);
+
+ // Wait to ensure stability
+ setTimeout(() => {
+ expect(eventTriggered).to.be.true;
+ expect(alternativeContentDetected).to.be.true;
+ expect(backToOriginalDetected).to.be.true;
+
+ // With clip="true", alternative should terminate at PRT + maxDuration
+ const expectedTerminationTime = expectedPresentationTime + expectedMaxDuration;
+ expect(alternativeEndTime).to.be.closeTo(expectedTerminationTime, 0.5);
+ done();
+ }, 1000); // Wait for VOD content stability
+ }
+ });
+
+ // Perform seek forward to simulate delay after player starts
+ player.registerEvent('playbackStarted', () => {
+ if (!seekPerformed) {
+ seekPerformed = true;
+
+ // Wait a moment for stable playback, then seek forward
+ setTimeout(() => {
+ const seekTime = 7; // Seek to 7 seconds - event should have started at 5s
+ player.seek(seekTime);
+ }, 2000);
+ }
+ });
+
+ // Handle errors
+ player.registerEvent('error', (e) => {
+ clearTimeout(timeout);
+ done(new Error(`Player error: ${JSON.stringify(e)}`));
+ });
+
+ }, 35000);
+
+ });
+});
\ No newline at end of file
diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-executeOnce.js b/test/functional/test/feature-support/alternative/alternative-mpd-executeOnce.js
new file mode 100644
index 0000000000..0b66fed64a
--- /dev/null
+++ b/test/functional/test/feature-support/alternative/alternative-mpd-executeOnce.js
@@ -0,0 +1,252 @@
+import Constants from '../../../../../src/streaming/constants/Constants.js';
+import Utils from '../../../src/Utils.js';
+import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js';
+import { expect } from 'chai';
+
+/**
+ * Utility function to modify a manifest by setting executeOnce to a specific value
+ * Also stores the original value for cleanup purposes
+ */
+function injectExecuteOnce(player, manifestUrl, executeOnceValue, callback) {
+ const mediaPlayer = player.player;
+ let originalValue = null;
+
+ mediaPlayer.retrieveManifest(manifestUrl, (manifest) => {
+ // Find the EventStream with the insert event
+ if (manifest.Period && manifest.Period[0] && manifest.Period[0].EventStream) {
+ manifest.Period[0].EventStream.forEach((eventStream) => {
+ if (eventStream.schemeIdUri === 'urn:mpeg:dash:event:alternativeMPD:insert:2025' && eventStream.Event) {
+ eventStream.Event.forEach((event) => {
+ if (event.InsertPresentation) {
+ // Store original value for cleanup
+ originalValue = event.InsertPresentation.executeOnce;
+ // Set executeOnce to the desired value
+ event.InsertPresentation.executeOnce = executeOnceValue;
+ }
+ });
+ }
+ });
+ }
+
+ // Attach the modified manifest
+ mediaPlayer.attachSource(manifest);
+
+ if (callback) {
+ callback(originalValue);
+ }
+ });
+}
+
+Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-executeOnce').forEach((item) => {
+ const name = item.name;
+ const url = item.url;
+
+ describe(`Alternative MPD executeOnce functionality tests for: ${name}`, () => {
+
+ let player;
+
+ before(() => {
+ player = initializeDashJsAdapterForAlternativMedia(item, url);
+ });
+
+ after(() => {
+ if (player) {
+ player.destroy();
+ }
+ });
+
+ it('should execute insert event only once, even after seek backwards', (done) => {
+ const videoElement = player.getVideoElement();
+ let firstEventTriggered = false;
+ let firstAlternativeContentDetected = false;
+ let firstBackToOriginalDetected = false;
+ let secondEventTriggered = false;
+ let secondAlternativeContentDetected = false;
+ let timeBeforeFirstSwitch = 0;
+ let eventTriggerCount = 0;
+ let alternativeContentStartCount = 0;
+
+ const timeout = setTimeout(() => {
+ done(new Error('Test timed out - executeOnce validation not completed within 35 seconds'));
+ }, 35000); // Extended timeout for seek operations
+
+ // Listen for alternative MPD INSERT events
+ player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.INSERT, () => {
+ eventTriggerCount++;
+ if (!firstEventTriggered) {
+ firstEventTriggered = true;
+ timeBeforeFirstSwitch = videoElement.currentTime;
+ } else {
+ secondEventTriggered = true;
+ }
+ });
+
+ // Listen for alternative content start events
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => {
+ if (data.event.mode === 'insert') {
+ alternativeContentStartCount++;
+ if (!firstAlternativeContentDetected) {
+ firstAlternativeContentDetected = true;
+ } else {
+ secondAlternativeContentDetected = true;
+ }
+ }
+ });
+
+ // Listen for alternative content end events
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => {
+ if (data.event.mode === 'insert' && !firstBackToOriginalDetected) {
+ firstBackToOriginalDetected = true;
+
+ // Wait a moment for stability, then seek backwards to before the event presentation time
+ setTimeout(() => {
+ const seekTime = timeBeforeFirstSwitch - 2; // Seek 2 seconds before the original event
+ player.seek(seekTime);
+
+ // Wait for seek to complete and playback to progress past the event time again
+ setTimeout(() => {
+ // Wait additional time to see if event retriggers
+ setTimeout(() => {
+ clearTimeout(timeout);
+
+ // Basic test validations
+ expect(firstEventTriggered).to.be.true;
+ expect(firstAlternativeContentDetected).to.be.true;
+ expect(firstBackToOriginalDetected).to.be.true;
+
+ // ExecuteOnce validations - event should NOT retrigger
+ expect(eventTriggerCount).to.equal(1);
+ expect(alternativeContentStartCount).to.equal(1);
+ expect(secondEventTriggered).to.be.false;
+ expect(secondAlternativeContentDetected).to.be.false;
+ done();
+ }, 8000); // Wait 8 seconds to verify no retriggering
+ }, 3000); // Wait 3 seconds for seek to complete
+ }, 2000); // Wait 2 seconds after alternative content ends
+ }
+ });
+
+ // Handle errors
+ player.registerEvent('error', (e) => {
+ console.error('Player error:', e);
+ });
+
+
+ }, 40000); // 40 second timeout for this complex test
+
+ it('should execute insert event multiple times when executeOnce=false, even after seek backwards', (done) => {
+ // Create a new player instance for this test with modified manifest
+ let testPlayer;
+
+ const cleanup = () => {
+ if (testPlayer) {
+ // Restore original executeOnce value to true
+ injectExecuteOnce(testPlayer, url, 'true', () => {
+ testPlayer.destroy();
+ });
+ }
+ };
+
+ const initPlayerWithModifiedManifest = () => {
+ testPlayer = initializeDashJsAdapterForAlternativMedia(item, null);
+
+ injectExecuteOnce(testPlayer, url, 'false', () => {
+ runExecuteOnceFalseTest();
+ });
+ };
+
+ const runExecuteOnceFalseTest = () => {
+ const videoElement = testPlayer.getVideoElement();
+ let firstEventTriggered = false;
+ let firstAlternativeContentDetected = false;
+ let firstBackToOriginalDetected = false;
+ let secondEventTriggered = false;
+ let secondAlternativeContentDetected = false;
+ let timeBeforeFirstSwitch = 0;
+ let eventTriggerCount = 0;
+ let alternativeContentStartCount = 0;
+
+ const timeout = setTimeout(() => {
+ cleanup();
+ done(new Error('Test timed out - executeOnce=false validation not completed within 45 seconds'));
+ }, 45000);
+
+ // Listen for alternative MPD INSERT events
+ testPlayer.registerEvent(Constants.ALTERNATIVE_MPD.URIS.INSERT, () => {
+ eventTriggerCount++;
+ if (!firstEventTriggered) {
+ firstEventTriggered = true;
+ timeBeforeFirstSwitch = videoElement.currentTime;
+ } else if (!secondEventTriggered) {
+ secondEventTriggered = true;
+ }
+ });
+
+ // Listen for alternative content start events
+ testPlayer.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => {
+ if (data.event.mode === 'insert') {
+ alternativeContentStartCount++;
+ if (!firstAlternativeContentDetected) {
+ firstAlternativeContentDetected = true;
+ } else if (!secondAlternativeContentDetected) {
+ secondAlternativeContentDetected = true;
+ }
+ }
+ });
+
+ // Listen for alternative content end events
+ testPlayer.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => {
+ if (data.event.mode === 'insert') {
+ if (!firstBackToOriginalDetected) {
+ firstBackToOriginalDetected = true;
+
+ // Wait a moment for stability, then seek backwards to before the event presentation time
+ setTimeout(() => {
+ const seekTime = timeBeforeFirstSwitch - 2;
+ testPlayer.seek(seekTime);
+
+ // Wait for seek to complete and playback to progress past the event time again
+ setTimeout(() => {
+ // Wait additional time to allow second event to trigger
+ setTimeout(() => {
+ clearTimeout(timeout);
+
+ // Basic test validations
+ expect(firstEventTriggered).to.be.true;
+ expect(firstAlternativeContentDetected).to.be.true;
+ expect(firstBackToOriginalDetected).to.be.true;
+
+ // ExecuteOnce=false validations - event SHOULD retrigger
+ expect(eventTriggerCount).to.be.at.least(2);
+ expect(alternativeContentStartCount).to.be.at.least(2);
+ expect(secondEventTriggered).to.be.true;
+ expect(secondAlternativeContentDetected).to.be.true;
+
+ cleanup();
+ done();
+ }, 10000); // Wait 10 seconds to allow second event to complete
+ }, 3000);
+ }, 2000);
+ }
+ }
+ });
+
+ // Handle errors
+ testPlayer.registerEvent('error', (e) => {
+ console.error('Player error:', e);
+ clearTimeout(timeout);
+ if (testPlayer) {
+ testPlayer.destroy();
+ }
+ done(new Error(`Player error: ${JSON.stringify(e)}`));
+ });
+
+ };
+
+ // Initialize the test
+ initPlayerWithModifiedManifest();
+
+ }, 50000); // 50 second timeout for this complex test
+
+ });
+});
\ No newline at end of file
diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-insert-vod.js b/test/functional/test/feature-support/alternative/alternative-mpd-insert-vod.js
new file mode 100644
index 0000000000..cced906e26
--- /dev/null
+++ b/test/functional/test/feature-support/alternative/alternative-mpd-insert-vod.js
@@ -0,0 +1,96 @@
+import Constants from '../../../../../src/streaming/constants/Constants.js';
+import Utils from '../../../src/Utils.js';
+import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js';
+import { expect } from 'chai';
+
+// Executes with:
+// test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd
+// test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd
+
+Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-insert-vod').forEach((item) => {
+ const name = item.name;
+ const url = item.url;
+
+ describe(`Alternative MPD Insert functionality tests for: ${name}`, () => {
+
+ let player;
+
+ before(() => {
+ player = initializeDashJsAdapterForAlternativMedia(item, url);
+ });
+
+ after(() => {
+ if (player) {
+ player.destroy();
+ }
+ });
+
+ it('should play original content, insert alternative content, then resume original at same position', (done) => {
+ const videoElement = player.getVideoElement();
+ let alternativeContentDetected = false;
+ let backToOriginalDetected = false;
+ let eventTriggered = false;
+ let timeBeforeSwitch = 0;
+ let timeAfterSwitch = 0;
+ let alternativeStartTime = 0;
+ let alternativeEndTime = 0;
+ let expectedAlternativeDuration = 0;
+
+ const timeout = setTimeout(() => {
+ done(new Error('Test timed out - alternative MPD insert event not completed within 25 seconds'));
+ }, 25000);
+
+ // Listen for alternative MPD INSERT events
+ player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.INSERT, () => {
+ eventTriggered = true;
+ timeBeforeSwitch = videoElement.currentTime;
+ // Validate that timeBeforeSwitch is close to presentation time
+ expect(timeBeforeSwitch).to.be.closeTo(5, 1);
+ });
+
+ // Listen for alternative content start event
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => {
+ if (data.event.mode === 'insert') {
+ alternativeContentDetected = true;
+ alternativeStartTime = Date.now();
+ expectedAlternativeDuration = data.event.duration;
+ }
+ });
+
+ // Listen for alternative content end event
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => {
+ if (data.event.mode === 'insert') {
+ timeAfterSwitch = videoElement.currentTime;
+ alternativeEndTime = Date.now();
+ backToOriginalDetected = true;
+ clearTimeout(timeout);
+
+ // Wait to ensure stability
+ setTimeout(() => {
+ expect(eventTriggered).to.be.true;
+ expect(alternativeContentDetected).to.be.true;
+ expect(backToOriginalDetected).to.be.true;
+
+ // Verify that alternative content played for its full duration
+ const actualAlternativeDuration = (alternativeEndTime - alternativeStartTime) / 1000; // Convert to seconds
+ expect(actualAlternativeDuration).to.be.at.least(expectedAlternativeDuration - 1); // Allow 1 second tolerance
+ expect(actualAlternativeDuration).to.be.at.most(expectedAlternativeDuration + 1.5); // Allow 1 second tolerance
+
+ // For INSERT mode, it expects to return close to the original presentation time
+ expect(Math.abs(timeAfterSwitch - timeBeforeSwitch)).to.be.below(1);
+ done();
+ }, 2000);
+ }
+ });
+
+
+ // Handle errors
+ player.registerEvent('error', (e) => {
+ clearTimeout(timeout);
+ done(new Error(`Player error: ${JSON.stringify(e)}`));
+ });
+
+ }, 35000);
+
+ });
+});
\ No newline at end of file
diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-replace-live.js b/test/functional/test/feature-support/alternative/alternative-mpd-replace-live.js
new file mode 100644
index 0000000000..fb4fb76c83
--- /dev/null
+++ b/test/functional/test/feature-support/alternative/alternative-mpd-replace-live.js
@@ -0,0 +1,153 @@
+import Constants from '../../../../../src/streaming/constants/Constants.js';
+import Utils from '../../../src/Utils.js';
+import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js';
+import { expect } from 'chai';
+
+/**
+ * Utility function to modify a live manifest by injecting Alternative MPD events
+ * This simulates the functionality from the demo.html tool for live to VOD and live to live scenarios
+ */
+function injectAlternativeMpdEvents(player, originalManifestUrl, alternativeManifestUrl, presentationTime, callback) {
+ // Access the underlying MediaPlayer instance
+ const mediaPlayer = player.player;
+
+ mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => {
+ manifest.Period[0].EventStream = [];
+
+ // Use the provided presentation time
+ const duration = 9000;
+ const earliestResolutionTimeOffset = 5000;
+ const maxDuration = 9000;
+
+ // Create the replace event
+ const replaceEvent = {
+ schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025',
+ timescale: 1000,
+ Event: [{
+ id: 1,
+ presentationTime: presentationTime,
+ duration: duration,
+ ReplacePresentation: {
+ url: alternativeManifestUrl,
+ earliestResolutionTimeOffset: earliestResolutionTimeOffset,
+ maxDuration: maxDuration,
+ clip: false,
+ }
+ }]
+ };
+
+ // Add the event to the manifest
+ manifest.Period[0].EventStream.push(replaceEvent);
+
+ // Attach the modified manifest using the MediaPlayer directly
+ mediaPlayer.attachSource(manifest);
+
+ if (callback) {
+ callback();
+ }
+ });
+}
+
+Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-replace-live').forEach((item) => {
+ const name = item.name;
+ const originalUrl = item.originalUrl;
+ const alternativeUrl = item.alternativeUrl;
+
+ describe(`Alternative MPD Replace Live functionality tests for: ${name}`, () => {
+
+ let player;
+ let presentationTime;
+
+ before((done) => {
+ // Initialize the player without attaching source immediately
+ player = initializeDashJsAdapterForAlternativMedia(item, null);
+
+ // Calculate presentation time for live content
+ // For live streams, use current time + offset to ensure the event is in the future
+ const currentPresentationTime = Date.now();
+ presentationTime = currentPresentationTime + 4000; // 4 seconds from now
+
+ // Use the utility function to inject Alternative MPD events
+ injectAlternativeMpdEvents(player, originalUrl, alternativeUrl, presentationTime, () => {
+ done();
+ });
+ });
+
+ after(() => {
+ if (player) {
+ player.destroy();
+ }
+ });
+
+ it('should play live content, switch to alternative content, then back to live content', (done) => {
+ const videoElement = player.getVideoElement();
+ let alternativeContentDetected = false;
+ let backToOriginalDetected = false;
+ let eventTriggered = false;
+ let timeAfterSwitch = 0;
+ let alternativeStartTime = 0;
+ let alternativeEndTime = 0;
+ let expectedAlternativeDuration = 0;
+
+
+ const timeout = setTimeout(() => {
+ done(new Error('Test timed out - alternative MPD replace event not completed within 30 seconds'));
+ }, 30000);
+
+ // Listen for alternative MPD REPLACE events
+ player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => {
+ eventTriggered = true;
+ });
+
+ // Listen for alternative content start event
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => {
+ if (data.event.mode === 'replace') {
+ alternativeContentDetected = true;
+ alternativeStartTime = Date.now();
+ expectedAlternativeDuration = data.event.duration;
+ const latency = player.getCurrentLiveLatency();
+ if (!isNaN(latency)){ //prevents execution if player is not loaded yet
+ let expectedStartTimeWithLatency = presentationTime + (latency * 1000);
+ expect(alternativeStartTime).to.be.closeTo(expectedStartTimeWithLatency, 1000); // Allow tolerance
+ }
+ }
+ });
+
+ // Listen for alternative content end event
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => {
+ if (data.event.mode === 'replace') {
+ timeAfterSwitch = videoElement.currentTime;
+ alternativeEndTime = Date.now();
+ backToOriginalDetected = true;
+ clearTimeout(timeout);
+
+ // Wait to ensure stability
+ setTimeout(() => {
+ expect(eventTriggered).to.be.true;
+ expect(alternativeContentDetected).to.be.true;
+ expect(backToOriginalDetected).to.be.true;
+
+ // Verify that alternative content played for its full duration
+ const actualAlternativeDuration = (alternativeEndTime - alternativeStartTime) / 1000; // Convert to seconds
+ expect(actualAlternativeDuration).to.be.closeTo(expectedAlternativeDuration, 1.5); // Allow 1 second tolerance
+
+ // For REPLACE mode from live to VOD, verify timing behavior
+ // The expected behavior is to return at presentationTime + duration or returnOffset
+ const expectedMinTime = data.event.presentationTime + data.event.duration;
+ expect(timeAfterSwitch).to.be.closeTo(expectedMinTime, 2); // Allow 2 seconds tolerance for live content
+ done();
+ }, 2000);
+ }
+ });
+
+
+ // Handle errors
+ player.registerEvent('error', (e) => {
+ clearTimeout(timeout);
+ done(new Error(`Player error: ${JSON.stringify(e)}`));
+ });
+
+ }, 40000); // Extended timeout for live content
+
+ });
+});
\ No newline at end of file
diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-replace-vod.js b/test/functional/test/feature-support/alternative/alternative-mpd-replace-vod.js
new file mode 100644
index 0000000000..2a58e1037f
--- /dev/null
+++ b/test/functional/test/feature-support/alternative/alternative-mpd-replace-vod.js
@@ -0,0 +1,94 @@
+import Constants from '../../../../../src/streaming/constants/Constants.js';
+import Utils from '../../../src/Utils.js';
+import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js';
+import { expect } from 'chai';
+
+// Executes with:
+// test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd
+// test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd
+
+Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-replace-vod').forEach((item) => {
+ const name = item.name;
+ const url = item.url;
+
+ describe(`Alternative MPD Replace functionality tests for: ${name}`, () => {
+
+ let player;
+
+ before(() => {
+ player = initializeDashJsAdapterForAlternativMedia(item, url);
+ });
+
+ after(() => {
+ if (player) {
+ player.destroy();
+ }
+ });
+
+ it('should play original content, switch to alternative content, then back to original', (done) => {
+ const videoElement = player.getVideoElement();
+ let alternativeContentDetected = false;
+ let backToOriginalDetected = false;
+ let eventTriggered = false;
+ let timeAfterSwitch = 0;
+ let alternativeStartTime = 0;
+ let alternativeEndTime = 0;
+ let expectedAlternativeDuration = 0;
+
+ const timeout = setTimeout(() => {
+ done(new Error('Test timed out - alternative MPD replace event not completed within 25 seconds'));
+ }, 25000); // 25 seconds should be enough for full test
+
+ // Listen for alternative MPD REPLACE events
+ player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => {
+ eventTriggered = true;
+ });
+
+ // Listen for alternative content start event
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => {
+ if (data.event.mode === 'replace') {
+ alternativeContentDetected = true;
+ alternativeStartTime = player.getCurrentTime();
+ expectedAlternativeDuration = data.event.duration;
+ // Validate that alternativeStartTime is close to presentation time
+ expect(alternativeStartTime).to.be.closeTo(5, 1);
+ }
+ });
+
+ // Listen for alternative content end event
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => {
+ if (data.event.mode === 'replace') {
+ timeAfterSwitch = videoElement.currentTime;
+ alternativeEndTime = player.getCurrentTime();
+ backToOriginalDetected = true;
+ clearTimeout(timeout);
+
+ // Wait to ensure stability
+ setTimeout(() => {
+ expect(eventTriggered).to.be.true;
+ expect(alternativeContentDetected).to.be.true;
+ expect(backToOriginalDetected).to.be.true;
+
+ // Verify that alternative content played for its full duration
+ const actualAlternativeDuration = alternativeEndTime - alternativeStartTime; // Both are in seconds now
+ expect(actualAlternativeDuration).to.be.at.least(expectedAlternativeDuration - 1); // Allow 1 second tolerance
+ expect(actualAlternativeDuration).to.be.at.most(expectedAlternativeDuration + 1.5); // Allow 1.5 second tolerance
+
+ // For REPLACE mode, it expectes to return on presentationTime + duration
+ const expectedMinTime = data.event.presentationTime + data.event.duration;
+ expect(timeAfterSwitch).equals(expectedMinTime);
+ done();
+ }, 2000);
+ }
+ });
+
+ // Handle errors
+ player.registerEvent('error', (e) => {
+ clearTimeout(timeout);
+ done(new Error(`Player error: ${JSON.stringify(e)}`));
+ });
+
+ }, 30000);
+
+ });
+});
\ No newline at end of file
diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-returnOffset.js b/test/functional/test/feature-support/alternative/alternative-mpd-returnOffset.js
new file mode 100644
index 0000000000..a8a9728530
--- /dev/null
+++ b/test/functional/test/feature-support/alternative/alternative-mpd-returnOffset.js
@@ -0,0 +1,119 @@
+import Constants from '../../../../../src/streaming/constants/Constants.js';
+import Utils from '../../../src/Utils.js';
+import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js';
+import { expect } from 'chai';
+
+/**
+ * Utility function to modify a live manifest by injecting Alternative MPD events with returnOffset
+ */
+function injectAlternativeMpdEventsWithReturnOffset(player, originalManifestUrl, alternativeManifestUrl, presentationTime, callback) {
+ // Access the underlying MediaPlayer instance
+ const mediaPlayer = player.player;
+
+ mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => {
+ manifest.Period[0].EventStream = [];
+
+ const duration = 8000;
+ const earliestResolutionTimeOffset = 5000;
+ const maxDuration = 8000;
+ const returnOffset = 3000;
+
+ const replaceEvent = {
+ schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025',
+ timescale: 1000,
+ Event: [{
+ id: 1,
+ presentationTime: presentationTime,
+ duration: duration,
+ ReplacePresentation: {
+ url: alternativeManifestUrl,
+ earliestResolutionTimeOffset: earliestResolutionTimeOffset,
+ maxDuration: maxDuration,
+ returnOffset: returnOffset,
+ clip: false,
+ }
+ }]
+ };
+
+ manifest.Period[0].EventStream.push(replaceEvent);
+
+ mediaPlayer.attachSource(manifest);
+
+ if (callback) {
+ callback();
+ }
+ });
+}
+
+Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-returnOffset').forEach((item) => {
+ const name = item.name;
+ const originalUrl = item.originalUrl;
+ const alternativeUrl = item.alternativeUrl;
+
+ describe(`Alternative MPD returnOffset functionality tests for: ${name}`, () => {
+
+ let player;
+ let presentationTime;
+
+ before((done) => {
+ player = initializeDashJsAdapterForAlternativMedia(item, null);
+
+ const currentPresentationTime = Date.now();
+ presentationTime = currentPresentationTime + 4000; // 4 seconds from now
+
+ // Use the utility function to inject Alternative MPD events with returnOffset
+ injectAlternativeMpdEventsWithReturnOffset(player, originalUrl, alternativeUrl, presentationTime, () => {
+ done();
+ });
+ });
+
+ after(() => {
+ if (player) {
+ player.destroy();
+ }
+ });
+
+ it('should return to main content at correct time based on returnOffset', (done) => {
+ const videoElement = player.getVideoElement();
+ let backToOriginalDetected = false;
+ let timeAfterSwitch = 0;
+ let eventPresentationTime = 0;
+ let eventReturnOffset = 0;
+
+ const timeout = setTimeout(() => {
+ done(new Error('Test timed out - returnOffset test not completed within 35 seconds'));
+ }, 35000);
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => {
+ if (data.event.mode === 'replace' && data.event.returnOffset !== undefined) {
+ eventPresentationTime = data.event.presentationTime;
+ eventReturnOffset = data.event.returnOffset;
+ backToOriginalDetected = true;
+ clearTimeout(timeout);
+
+ // Wait for playback to stabilize
+ setTimeout(() => {
+ timeAfterSwitch = videoElement.currentTime;
+ expect(backToOriginalDetected).to.be.true;
+
+ // RT = PRT + returnOffset (where PRT is presentationTime)
+ const expectedReturnTime = eventPresentationTime + eventReturnOffset;
+
+ // Allow 2 seconds tolerance for live content timing variations
+ expect(timeAfterSwitch).to.be.closeTo(expectedReturnTime, 2);
+
+ done();
+ }, 2000);
+ }
+ });
+
+ // Handle errors
+ player.registerEvent('error', (e) => {
+ clearTimeout(timeout);
+ done(new Error(`Player error: ${JSON.stringify(e)}`));
+ });
+
+ }, 40000); // Extended timeout for live content
+
+ });
+});
\ No newline at end of file
diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-status-update-live.js b/test/functional/test/feature-support/alternative/alternative-mpd-status-update-live.js
new file mode 100644
index 0000000000..c437cc1282
--- /dev/null
+++ b/test/functional/test/feature-support/alternative/alternative-mpd-status-update-live.js
@@ -0,0 +1,205 @@
+import Constants from '../../../../../src/streaming/constants/Constants.js';
+import Utils from '../../../src/Utils.js';
+import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js';
+import { expect } from 'chai';
+
+/**
+ * Utility function to modify a live manifest by injecting Alternative MPD events without maxDuration
+ * This simulates the initial event that starts alternative content playback without a preset duration limit
+ */
+function injectInitialAlternativeMpdEvent(player, originalManifestUrl, alternativeManifestUrl, presentationTime, callback) {
+ const mediaPlayer = player.player;
+
+ mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => {
+ manifest.Period[0].EventStream = [];
+
+ const duration = 15000; // 15 seconds default duration
+ const earliestResolutionTimeOffset = 3000;
+ const uniqueEventId = Math.floor(presentationTime / 1000); // Use timestamp-based unique ID
+
+ // Create the replace event WITHOUT maxDuration initially
+ const replaceEvent = {
+ schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025',
+ timescale: 1000,
+ Event: [{
+ id: uniqueEventId,
+ presentationTime: presentationTime,
+ duration: duration,
+ ReplacePresentation: {
+ url: alternativeManifestUrl,
+ earliestResolutionTimeOffset: earliestResolutionTimeOffset,
+ // NOTE: No maxDuration set initially
+ clip: false,
+ }
+ }]
+ };
+
+ manifest.Period[0].EventStream.push(replaceEvent);
+ mediaPlayer.attachSource(manifest);
+
+ if (callback) {
+ callback();
+ }
+ });
+}
+
+/**
+ * Utility function to inject a status update event with maxDuration during active playback
+ * This simulates the status="update" scenario where maxDuration is added mid-execution
+ * Instead of modifying the manifest, we inject the event via manifest update simulation
+ */
+function injectStatusUpdateEvent(player, originalManifestUrl, alternativeManifestUrl, presentationTime, newMaxDuration, callback) {
+ const mediaPlayer = player.player;
+
+ // Status updates should be processed like MPD updates
+ // So we simulate an MPD update that contains the status="update" event
+ mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => {
+ // Keep existing EventStreams and add the status update
+ if (!manifest.Period[0].EventStream) {
+ manifest.Period[0].EventStream = [];
+ }
+
+ const duration = 15000; // Keep same duration
+ const earliestResolutionTimeOffset = 3000;
+ const uniqueEventId = Math.floor(presentationTime / 1000); // Same ID as original event
+
+ // Create the status update event that will update the existing event
+ // This event should have the same ID as the original event but with status="update"
+ const statusUpdateEvent = {
+ schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025',
+ timescale: 1000,
+ Event: [{
+ id: uniqueEventId, // Same ID as the original event to update it
+ presentationTime: presentationTime,
+ duration: duration,
+ status: 'update', // This marks it as an update event
+ ReplacePresentation: {
+ url: alternativeManifestUrl,
+ earliestResolutionTimeOffset: earliestResolutionTimeOffset,
+ maxDuration: newMaxDuration, // NEW: Add maxDuration via status update
+ clip: false,
+ }
+ }]
+ };
+
+ let existingEventStream = manifest.Period[0].EventStream.find(
+ stream => stream.schemeIdUri === 'urn:mpeg:dash:event:alternativeMPD:replace:2025'
+ );
+
+ if (existingEventStream) {
+ // Add the status update event to the existing EventStream
+ existingEventStream.Event.push(statusUpdateEvent.Event[0]);
+ } else {
+ // Add as a new EventStream (this creates the update scenario)
+ manifest.Period[0].EventStream.push(statusUpdateEvent);
+ }
+
+ // Re-attach the modified manifest to trigger processing of the status update
+ mediaPlayer.attachSource(manifest);
+
+ if (callback) {
+ callback();
+ }
+ });
+}
+
+Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-status-update-live').forEach((item) => {
+ const name = item.name;
+ const originalUrl = item.originalUrl;
+ const alternativeUrl = item.alternativeUrl;
+
+ describe(`Alternative MPD Status Update Live functionality tests for: ${name}`, () => {
+
+ let player;
+ let presentationTime;
+ let newMaxDuration;
+
+ before((done) => {
+ player = initializeDashJsAdapterForAlternativMedia(item, null);
+
+ // For live streams, use current time + offset to ensure the event is in the future
+ const currentPresentationTime = Date.now();
+ presentationTime = currentPresentationTime + 6000; // 6 seconds from now (longer to avoid timing issues)
+ newMaxDuration = 8000; // 8 seconds - shorter than original duration
+
+ injectInitialAlternativeMpdEvent(player, originalUrl, alternativeUrl, presentationTime, () => {
+ done();
+ });
+ });
+
+ after(() => {
+ if (player) {
+ player.destroy();
+ }
+ });
+
+ it('should start alternative content without maxDuration, then update with maxDuration via status update and terminate early', (done) => {
+ let alternativeContentDetected = false;
+ let statusUpdateApplied = false;
+ let backToOriginalDetected = false;
+ let eventTriggered = false;
+ let alternativeStartTime = 0;
+ let alternativeEndTime = 0;
+ let updatedMaxDuration = null;
+
+ const timeout = setTimeout(() => {
+ done(new Error('Test timed out - status update live event not completed within 35 seconds'));
+ }, 35000);
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => {
+ eventTriggered = true;
+ });
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => {
+ if (data.event.mode === 'replace') {
+ alternativeContentDetected = true;
+ alternativeStartTime = Date.now();
+
+ setTimeout(() => {
+ injectStatusUpdateEvent(player, originalUrl, alternativeUrl, presentationTime, newMaxDuration, () => {
+ statusUpdateApplied = true;
+ });
+ }, 3000);
+ }
+ });
+
+ player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => {
+ if (data.event.mode === 'replace') {
+ alternativeEndTime = Date.now();
+ backToOriginalDetected = true;
+ updatedMaxDuration = data.event.maxDuration;
+
+ clearTimeout(timeout);
+
+ const actualAlternativeDuration = (alternativeEndTime - alternativeStartTime) / 1000;
+
+ // Wait to ensure stability
+ setTimeout(() => {
+ expect(eventTriggered).to.be.true;
+ expect(alternativeContentDetected).to.be.true;
+ expect(statusUpdateApplied).to.be.true;
+ expect(backToOriginalDetected).to.be.true;
+
+ // Verify that the status update was applied and maxDuration was set
+ const expectedMaxDurationInSeconds = newMaxDuration / 1000;
+ expect(updatedMaxDuration).to.equal(expectedMaxDurationInSeconds);
+
+ // Verify that alternative content terminated early due to maxDuration from status update
+ expect(actualAlternativeDuration).to.be.lessThan(10); // Much less than original 15s
+ expect(actualAlternativeDuration).to.be.lessThan(12);
+
+ done();
+ }, 2000);
+ }
+ });
+
+ // Handle errors
+ player.registerEvent('error', (e) => {
+ clearTimeout(timeout);
+ done(new Error(`Player error: ${JSON.stringify(e)}`));
+ });
+
+ }, 45000); // Extended timeout for live content with status updates
+
+ });
+});
\ No newline at end of file
diff --git a/test/functional/view/index.html b/test/functional/view/index.html
index 585786db6e..22faabecdb 100644
--- a/test/functional/view/index.html
+++ b/test/functional/view/index.html
@@ -14,6 +14,7 @@
+