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 example + + + + + + +
+
+ +
+
+
+
+

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

  • +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + + + + + + + + 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
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + Replace: Replaces main content with alternative content.
+ Insert: Inserts alternative content, then returns to the same point in main content. +
+
+ +
+
+ + + Time in main video to trigger event +
+
+ + +
+
+ +
+
+ + + Offset in main content after alternative ends +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+
+
+
+ + + +
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + 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
+
+ + +
+ +
+ + +
+ +
+ + + Time from now to trigger replace event +
+ +
+ + +
+ + +
+
+
+
+ + + +
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + 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
+
+ + +
+ +
+ + +
+ +
+
+ + + Time from now to trigger replace event +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+
+
+
+ + + +
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + \ 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 content URL +
+ +
+ + + 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. +
+ +
+
+ + + Time in main video to trigger insert +
+
+ + + Maximum duration for insert +
+
+ +
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+
+
+
+ + + +
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + 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 code

'; +codeOutput.innerHTML += '

Source code

'; /** * 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 @@
+