diff --git a/samples/alternative-demo/demo.html b/samples/alternative-demo/demo.html new file mode 100644 index 0000000000..46cbe62c0c --- /dev/null +++ b/samples/alternative-demo/demo.html @@ -0,0 +1,467 @@ + + + + + Alternative Config + + + + + +
+
+
+
+ + +
+
+
+
+ + + +
+
+
+
Type
+
Alternative Url
+
Presentation Time (ms)
+
Duration (ms)
+
Earliest Resolution Time Offset (ms)
+
Max Duration (ms)
+
Return Offset (ms)
+
Clip
+
Start At Playhead
+
+
+
+
+ +
+
+
+
+ +
+
+
Manifest will appear here...
+
+
+ +
+
+ + + + + diff --git a/samples/develop.html b/samples/develop.html index e3ec0e9dac..1d42a97e8f 100644 --- a/samples/develop.html +++ b/samples/develop.html @@ -43,15 +43,69 @@ } diff --git a/samples/players-synchronization/followerClient.html b/samples/players-synchronization/followerClient.html new file mode 100644 index 0000000000..08f581f429 --- /dev/null +++ b/samples/players-synchronization/followerClient.html @@ -0,0 +1,97 @@ + + + + + + Follower client + + + + + + + + + + + + +
+
+
+

Follower client

+
+
+
+
+ + + +
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/samples/players-synchronization/globalSync.html b/samples/players-synchronization/globalSync.html new file mode 100644 index 0000000000..5453c0596e --- /dev/null +++ b/samples/players-synchronization/globalSync.html @@ -0,0 +1,96 @@ + + + + + + Follower client + + + + + + + + + + + + +
+
+
+

Follower client

+
+
+
+
+ + + +
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/samples/players-synchronization/leaderClient.html b/samples/players-synchronization/leaderClient.html new file mode 100644 index 0000000000..aedcc4ceaa --- /dev/null +++ b/samples/players-synchronization/leaderClient.html @@ -0,0 +1,92 @@ + + + + + + Leader client + + + + + + + + + + + + +
+
+
+

Leader client

+
+
+
+
+ + + +
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/samples/players-synchronization/playerSynchronizationPlugin.js b/samples/players-synchronization/playerSynchronizationPlugin.js new file mode 100644 index 0000000000..30b1f7379b --- /dev/null +++ b/samples/players-synchronization/playerSynchronizationPlugin.js @@ -0,0 +1,179 @@ +const CMCD_MODE_QUERY = 'query'; +let leaderTimestamp; +let leaderPlayhead; +let playbackRate; + +let lastInterval; + +(() => { + const syncPlayer = (player, config) => { + if (!leaderTimestamp || !leaderPlayhead || !playbackRate) { + return; + } + + const currentTimestamp = Date.now(); + const timeElapsed = (currentTimestamp - leaderTimestamp) / 1000; + const timeToSeek = leaderPlayhead + timeElapsed * playbackRate; + + const currentRepresentation = player.getCurrentRepresentationForType('video'); + const currentFrameRate = currentRepresentation.frameRate; + const frameDelay = Math.min(config.frameDelay, currentFrameRate); + + const SEEK_THRESHOLD = config.seekThreshold ?? (currentFrameRate / 100); + const SYNC_THRESHOLD = (currentFrameRate / 1000) * frameDelay; + + const getPlayersTimeDifference = () => Math.abs(timeToSeek - player.time()); + + const playersDifference = getPlayersTimeDifference(); + if (playersDifference > SEEK_THRESHOLD) { + player.seekToPresentationTime(timeToSeek); + player.setPlaybackRate(playbackRate); + if (lastInterval) { + clearInterval(lastInterval); + lastInterval = null; + } + } else if (playersDifference > SYNC_THRESHOLD) { + if (lastInterval) { + return; + } + + const isAheadOfTheLeader = player.time() > timeToSeek; + const catchUpRate = Math.max(config.catchUpRate, 1); + const speedModification = isAheadOfTheLeader ? 1 / catchUpRate : catchUpRate; + + player.setPlaybackRate(speedModification * playbackRate); + const interval = setInterval(() => { + lastInterval = interval; + const currentDifference = getPlayersTimeDifference(); + if (currentDifference <= SYNC_THRESHOLD) { + player.setPlaybackRate(playbackRate); + lastInterval = null; + clearInterval(interval); + } else if (currentDifference > SEEK_THRESHOLD) { + player.seekToPresentationTime(timeToSeek); + player.setPlaybackRate(playbackRate); + lastInterval = null; + clearInterval(interval); + } + }, 10); + } + }; + + const setupCMCD = (player, config) => { + player.updateSettings({ + streaming: { + cmcd: { + enabled: true, + version: 2, + reporting: { + eventMode: { + enabled: true, + mode: CMCD_MODE_QUERY, + interval: 5000, + enabledKeys: ['sid', 'cid', 'pr', 'pt', 'ts'], + requestUrl: config.url, + requestMethod: 'POST', + } + }, + sid: config.id, + mode: CMCD_MODE_QUERY, + } + } + }); + }; + + const parseCMSDHeader = (response) => { + console.log('CMSD Header:', response); + // const cmsdHeader = response.headers('CMSD-Dynamic'); + // if (!cmsdHeader) { + // return null; + // } + + // const match = cmsdHeader.match(/com\.svta-syncinfo="([^"]+)"/); + // if (!match || !match[1]) { + // return null; + // } + + // const [latencyTarget, referencePlayhead, referenceTimestamp] = match[1].split(',').map(Number); + // return { latencyTarget, referencePlayhead, referenceTimestamp }; + }; + + const configInterceptors = (player, config) => { + if (config.globalSync) { + globalSyncInterceptor(player, config); + } else if (config.leaderId) { + leaderSyncInterceptor(player, config); + } + + } + + const globalSyncInterceptor = (player) => { + player.addResponseInterceptor((response) => { + if (response.cmcdMode !== 'event') { + return Promise.resolve(response); + } + + const cmsdData = parseCMSDHeader(response); + if (cmsdData) { + console.log('CMSD Data:', cmsdData) + } + return Promise.resolve(response); + }); + } + + const leaderSyncInterceptor = (player, config) => { + player.addRequestInterceptor((request) => { + const { filteredCmcdData } = request; + if (filteredCmcdData) { + filteredCmcdData['synchronization-leader-sid'] = config.leaderId; + } + return Promise.resolve(request); + }); + + let firstRun = true; + player.addResponseInterceptor((response) => { + if (response.cmcdMode === 'event') { + response.json().then((data) => { + if (!data || !data.ts || !data.pt) { + return Promise.resolve(response); + } + const { ts, pt, pr } = data; + leaderTimestamp = Number(ts); + leaderPlayhead = Number(pt); + playbackRate = Number(pr ?? 1); + + if (firstRun) { + syncPlayer(player, config); + firstRun = false; + } + + return Promise.resolve(response); + }); + } + return Promise.resolve(response); + }); + } + + + window.playerSynchronization = { + addLeader(player, config) { + setupCMCD(player, config); + }, + addFollower(player, config) { + setupCMCD(player, config); + configInterceptors(player, config); + + setInterval(() => { + syncPlayer(player, config); + }, config.syncInterval ?? 5000); + + let seeking = false; + player.on(dashjs.MediaPlayer.events.PLAYBACK_SEEKED, () => { + if (!seeking) { + syncPlayer(player, config); + seeking = true; + } + }); + } + }; +})(); diff --git a/src/streaming/constants/Constants.js b/src/streaming/constants/Constants.js index c0d88e3937..d5c0f337f2 100644 --- a/src/streaming/constants/Constants.js +++ b/src/streaming/constants/Constants.js @@ -229,7 +229,7 @@ export default { * TODO: Confirm keys and create CMCD AVAILABLE KEYS arrays for CMCD v2 and Response Mode * TODO: Add support for more keys */ - CMCD_AVAILABLE_KEYS_EVENT: ['bg', 'br', 'bs', 'bl', 'mtp', 'sf', 'e', 'int', 'sta', 'ts', 'sid', 'cid', 'sf', 'v', 'lb', 'pr', 'ltc', 'msd'], + CMCD_AVAILABLE_KEYS_EVENT: ['bg', 'br', 'bs', 'bl', 'mtp', 'sf', 'e', 'int', 'sta', 'ts', 'sid', 'cid', 'sf', 'v', 'lb', 'pr', 'ltc', 'msd', 'pt'], /** * @constant {string} CMCD_AVAILABLE_REQUESTS specifies all the availables requests type for CMCD metrics. diff --git a/src/streaming/models/CmcdModel.js b/src/streaming/models/CmcdModel.js index 4143921ecf..2c9ffe1590 100644 --- a/src/streaming/models/CmcdModel.js +++ b/src/streaming/models/CmcdModel.js @@ -44,6 +44,7 @@ import {CmcdStreamType} from '@svta/common-media-library/cmcd/CmcdStreamType'; import {CmcdStreamingFormat} from '@svta/common-media-library/cmcd/CmcdStreamingFormat'; import {encodeCmcd} from '@svta/common-media-library/cmcd/encodeCmcd'; import {toCmcdHeaders} from '@svta/common-media-library/cmcd/toCmcdHeaders'; +import CustomParametersModel from '../models/CustomParametersModel.js'; const CMCD_VERSION = 1; const DEFAULT_INCLUDE_IN_REQUESTS = 'segment'; @@ -61,6 +62,7 @@ function CmcdModel() { serviceDescriptionController, throughputController, streamProcessors, + customParametersModel, _eventTimeoutId, _msdSent, _lastMediaTypeRequest, @@ -77,6 +79,7 @@ function CmcdModel() { function setup() { dashManifestModel = DashManifestModel(context).getInstance(); logger = debug.getLogger(instance); + customParametersModel = CustomParametersModel(context).getInstance(); _resetInitialSettings(); } @@ -97,6 +100,7 @@ function CmcdModel() { eventBus.on(MediaPlayerEvents.PLAYBACK_ENDED, _onPlaybackEnded, instance); eventBus.on(MediaPlayerEvents.ALTERNATIVE_PLAYBACK_PLAYING, _onAlternativeStarted, instance); eventBus.on(MediaPlayerEvents.ALTERNATIVE_PLAYBACK_ENDED, _onAlternativeEnded, instance); + eventBus.on(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _onPlaybackTimeUpdated, instance); const cmcdEventMode = _getCmcdEventData(); if (cmcdEventMode){ @@ -236,43 +240,85 @@ function CmcdModel() { //_sendCmcdEventData(_getCmcdEventData(),'c') } + function _onPlaybackTimeUpdated(data) { + // Revisar pt + internalData.pt = data.time; + internalData.ts = Date.now(); + } + function _sendCmcdEventData(cmcdEventMode, eventKeyValue = null) { const cmcdData = _getGenericCmcdData(null); + var requestUrl = cmcdEventMode.requestUrl; + var headers = {} // Add the event key data. cmcdData.e = eventKeyValue const filteredCmcdData = _applyWhitelist(cmcdData, 3); - - var requestUrl = cmcdEventMode.requestUrl; - var headers = {} - - if (cmcdEventMode.mode === Constants.CMCD_MODE_QUERY) { - const additionalQueryParameter = []; - const cmcdQueryParams = encodeCmcd(filteredCmcdData); - if (cmcdQueryParams) { - additionalQueryParameter.push({key: CMCD_PARAM, value: cmcdQueryParams}); + _applyRequestInterceptors({ + url: requestUrl, + method: cmcdEventMode.requestMethod, + headers, + filteredCmcdData + }).then(request => { + if (cmcdEventMode.mode === Constants.CMCD_MODE_QUERY) { + const cmcdQueryParams = encodeCmcd(filteredCmcdData); + if (cmcdQueryParams) { + request.url = Utils.addAdditionalQueryParameterToUrl(request.url, [ + { key: CMCD_PARAM, value: cmcdQueryParams } + ]); + } + } else if (cmcdEventMode.mode === Constants.CMCD_MODE_HEADER) { + request.headers = toCmcdHeaders(filteredCmcdData); } - requestUrl = Utils.addAdditionalQueryParameterToUrl(requestUrl, additionalQueryParameter); - } else if (cmcdEventMode.mode === Constants.CMCD_MODE_HEADER) { - headers = toCmcdHeaders(filteredCmcdData) - } - fetch(requestUrl, { - method: cmcdEventMode.requestMethod, - headers: headers + return fetch(request.url, { + method: request.method, + headers: request.headers + }); }).then(response => { - console.log('Event CMCD data sent successfully:', response); + response.cmcdMode = 'event'; + return _applyResponseInterceptors(response); }).catch(error => { console.error('Error sending event CMCD data:', error); }); + + } + + function _applyRequestInterceptors(httpRequest) { + const interceptors = customParametersModel.getRequestInterceptors(); + if (!interceptors) { + return Promise.resolve(httpRequest); + } + + return interceptors.reduce((prev, next) => { + return prev.then((request) => { + return next(request); + }); + }, Promise.resolve(httpRequest)); + } + + function _applyResponseInterceptors(response) { + const interceptors = customParametersModel.getResponseInterceptors(); + if (!interceptors) { + return Promise.resolve(response); + } + + return interceptors.reduce((prev, next) => { + return prev.then(resp => { + return next(resp); + }); + }, Promise.resolve(response)); } function _startCmcdEventTimer(interval, eventMode) { - _eventTimeoutId = setTimeout(() => { + setInterval(() => { _sendCmcdEventData(eventMode, 't') - // Restart the timer - _startCmcdEventTimer(interval, eventMode); - }, interval); + }, interval); + // _eventTimeoutId = setTimeout(() => { + // _sendCmcdEventData(eventMode, 't') + // // Restart the timer + // _startCmcdEventTimer(interval, eventMode); + // }, interval); } function _getCmcdEventData() { @@ -749,12 +795,14 @@ function CmcdModel() { data.sf = internalData.sf; } + data.pt = internalData.pt ?? 0; + // Add v2 mandatory keys if (request && internalData.v === 2) { data.url = request.url.split('?')[0]; // remove potential cmcd query params } if (internalData.v === 2) { - data.ts = Date.now(); + data.ts = internalData.ts; } if (internalData.bg){ data.bg = internalData.bg @@ -941,6 +989,7 @@ function CmcdModel() { eventBus.off(MediaPlayerEvents.PLAYBACK_ENDED, _onPlaybackEnded, instance); eventBus.off(MediaPlayerEvents.ALTERNATIVE_PLAYBACK_PLAYING, _onAlternativeStarted, instance); eventBus.off(MediaPlayerEvents.ALTERNATIVE_PLAYBACK_ENDED, _onAlternativeEnded, instance); + eventBus.off(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _onPlaybackTimeUpdated, instance); _resetInitialSettings(); }