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...
+
+
+
+ +
+
+ + + + + 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...
+
+
+
+ +
+
+ + + + + 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...
+
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/samples/samples.json b/samples/samples.json index b883f2f47b..040f490820 100644 --- a/samples/samples.json +++ b/samples/samples.json @@ -840,6 +840,50 @@ } ] }, + { + "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" + ] + } + ] + }, { "section": "MPEG-5 Part 2 - LCEVC", "samples": [ diff --git a/src/core/events/CoreEvents.js b/src/core/events/CoreEvents.js index 8bffb0aa56..6e52461bb6 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.BYTES_APPENDED_END_FRAGMENT = 'bytesAppendedEndFragment'; @@ -54,6 +56,7 @@ 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.LOADING_COMPLETED = 'loadingCompleted'; this.LOADING_PROGRESS = 'loadingProgress'; diff --git a/src/dash/constants/DashConstants.js b/src/dash/constants/DashConstants.js index eb71534267..f1aaa391a8 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', @@ -180,6 +185,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 66a125a073..5a39de6b1a 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'; @@ -1251,6 +1252,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) { // toString is used to manage both regular and namespaced tags @@ -1274,6 +1290,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; 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..df26e78a3a 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, alternative = false) { const requestStartDate = new Date(); const request = new TextRequest(url, HTTPRequest.MPD_TYPE); @@ -226,7 +226,12 @@ function ManifestLoader(config) { manifest.loadedTime = new Date(); xlinkController.resolveManifestOnLoad(manifest); - eventBus.trigger(Events.ORIGINAL_MANIFEST_LOADED, { originalManifest: data }); + if (alternative) { + eventBus.trigger(Events.ORIGINAL_ALTERNATIVE_MANIFEST_LOADED, { manifest: data }); + } else { + eventBus.trigger(Events.ORIGINAL_MANIFEST_LOADED, { originalManifest: data }); + } + } else { eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, { manifest: null, diff --git a/src/streaming/MediaManager.js b/src/streaming/MediaManager.js new file mode 100644 index 0000000000..a5574f5ad7 --- /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; \ No newline at end of file diff --git a/src/streaming/MediaPlayer.js b/src/streaming/MediaPlayer.js index b47a1144e3..f268a4b431 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'; @@ -140,6 +141,7 @@ function MediaPlayer() { throughputController, schemeLoaderFactory, timelineConverter, + alternativeMediaController, mediaController, protectionController, metricsReportingController, @@ -221,6 +223,9 @@ function MediaPlayer() { if (config.gapController) { gapController = config.gapController; } + if (config.alternativeMediaController) { + alternativeMediaController = config.alternativeMediaController; + } if (config.throughputController) { throughputController = config.throughputController } @@ -317,6 +322,10 @@ function MediaPlayer() { schemeLoaderFactory = SchemeLoaderFactory(context).getInstance(); } + if (!alternativeMediaController) { + alternativeMediaController = AlternativeMediaController(context).getInstance(); + } + if (!playbackController) { playbackController = PlaybackController(context).getInstance(); } @@ -387,6 +396,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 +472,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 +604,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 +2149,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 @@ -2434,6 +2467,7 @@ function MediaPlayer() { } } textController.reset(); + alternativeMediaController.reset(); cmcdModel.reset(); cmsdModel.reset(); } @@ -2566,6 +2600,7 @@ function MediaPlayer() { textController.initialize(); gapController.initialize(); catchupController.initialize(); + alternativeMediaController.initialize(); cmcdModel.initialize(autoPlay); cmsdModel.initialize(); contentSteeringController.initialize(); @@ -2925,6 +2960,7 @@ function MediaPlayer() { setProtectionData, setRepresentationForTypeById, setRepresentationForTypeByIndex, + setAlternativeVideoElement, setTextTrack, setVolume, setXHRWithCredentialsForType, diff --git a/src/streaming/MediaPlayerEvents.js b/src/streaming/MediaPlayerEvents.js index c6b39b6ee3..1a02b6c65a 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 ce4443665b..f5f33cd97b 100644 --- a/src/streaming/constants/Constants.js +++ b/src/streaming/constants/Constants.js @@ -331,6 +331,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/PlaybackController.js b/src/streaming/controllers/PlaybackController.js index 918a41bee7..819b9a310d 100644 --- a/src/streaming/controllers/PlaybackController.js +++ b/src/streaming/controllers/PlaybackController.js @@ -267,6 +267,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; @@ -705,7 +724,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() { @@ -922,6 +943,7 @@ function PlaybackController() { getAvailabilityStartTime, getBufferLevel, getCurrentLiveLatency, + getDvrWindowStart, getEnded, getInitialCatchupModeActivated, getIsDynamic, @@ -947,6 +969,7 @@ function PlaybackController() { seek, seekToCurrentLive, seekToOriginalLive, + seekToStartDvrWindow, setConfig, updateCurrentTime, }; diff --git a/test/functional/adapter/DashJsAdapter.js b/test/functional/adapter/DashJsAdapter.js index cc7e2b236d..9f908a1cfa 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 @@
+