Skip to content

Commit

Permalink
feat: add daterange support (#1402)
Browse files Browse the repository at this point in the history
  • Loading branch information
harisha-swaminathan authored Jul 24, 2023
1 parent fe25a04 commit 7c0e968
Show file tree
Hide file tree
Showing 8 changed files with 536 additions and 4 deletions.
12 changes: 10 additions & 2 deletions src/playlist-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { codecsForPlaylist, unwrapCodecList, codecCount } from './util/codecs.js
import { createMediaTypes, setupMediaGroups } from './media-groups';
import logger from './util/logger';
import {merge, createTimeRanges} from './util/vjs-compat';
import { addMetadata, createMetadataTrackIfNotExists } from './util/text-tracks';
import { addMetadata, createMetadataTrackIfNotExists, addDateRangeMetadata } from './util/text-tracks';

const ABORT_EARLY_EXCLUSION_SECONDS = 60 * 2;

Expand Down Expand Up @@ -265,7 +265,7 @@ export class PlaylistController extends videojs.EventTarget {
// PlaylistLoader should be used.
this.mainPlaylistLoader_ = this.sourceType_ === 'dash' ?
new DashPlaylistLoader(src, this.vhs_, merge(this.requestOptions_, { addMetadataToTextTrack: this.addMetadataToTextTrack.bind(this) })) :
new PlaylistLoader(src, this.vhs_, this.requestOptions_);
new PlaylistLoader(src, this.vhs_, merge(this.requestOptions_, { addDateRangesToTextTrack: this.addDateRangesToTextTrack_.bind(this) }));
this.setupMainPlaylistLoaderListeners_();

// setup segment loaders
Expand Down Expand Up @@ -2024,6 +2024,14 @@ export class PlaylistController extends videojs.EventTarget {
return Config.BUFFER_HIGH_WATER_LINE;
}

addDateRangesToTextTrack_(dateRanges) {
createMetadataTrackIfNotExists(this.inbandTextTracks_, 'com.apple.streaming', this.tech_);
addDateRangeMetadata({
inbandTextTracks: this.inbandTextTracks_,
dateRanges
});
}

addMetadataToTextTrack(dispatchType, metadataArray, videoDuration) {
const timestampOffset = this.sourceUpdater_.videoBuffer ?
this.sourceUpdater_.videoTimestampOffset() : this.sourceUpdater_.audioTimestampOffset();
Expand Down
23 changes: 23 additions & 0 deletions src/playlist-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from './manifest';
import {getKnownPartCount} from './playlist.js';
import {merge} from './util/vjs-compat';
import DateRangesStorage from './util/date-ranges';

const { EventTarget } = videojs;

Expand Down Expand Up @@ -391,19 +392,40 @@ export default class PlaylistLoader extends EventTarget {
this.src = src;
this.vhs_ = vhs;
this.withCredentials = withCredentials;
this.addDateRangesToTextTrack_ = options.addDateRangesToTextTrack;

const vhsOptions = vhs.options_;

this.customTagParsers = (vhsOptions && vhsOptions.customTagParsers) || [];
this.customTagMappers = (vhsOptions && vhsOptions.customTagMappers) || [];
this.llhls = vhsOptions && vhsOptions.llhls;
this.dateRangesStorage_ = new DateRangesStorage();

// initialize the loader state
this.state = 'HAVE_NOTHING';

// live playlist staleness timeout
this.handleMediaupdatetimeout_ = this.handleMediaupdatetimeout_.bind(this);
this.on('mediaupdatetimeout', this.handleMediaupdatetimeout_);
this.on('loadedplaylist', this.handleLoadedPlaylist_.bind(this));
}

handleLoadedPlaylist_() {
const mediaPlaylist = this.media();

if (!mediaPlaylist) {
return;
}

this.dateRangesStorage_.setOffset(mediaPlaylist.segments);
this.dateRangesStorage_.setPendingDateRanges(mediaPlaylist.dateRanges);
const availableDateRanges = this.dateRangesStorage_.getDateRangesToProcess();

if (!availableDateRanges.length) {
return;
}

this.addDateRangesToTextTrack_(availableDateRanges);
}

handleMediaupdatetimeout_() {
Expand Down Expand Up @@ -534,6 +556,7 @@ export default class PlaylistLoader extends EventTarget {
this.stopRequest();
window.clearTimeout(this.mediaUpdateTimeout);
window.clearTimeout(this.finalRenditionTimeout);
this.dateRangesStorage_ = new DateRangesStorage();

this.off();
}
Expand Down
108 changes: 108 additions & 0 deletions src/util/date-ranges.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
export default class DateRangesStorage {
constructor() {
this.offset_ = null;
this.pendingDateRanges_ = new Map();
this.processedDateRanges_ = new Map();
}

setOffset(segments = []) {
// already set
if (this.offset_ !== null) {
return;
}
// no segment to process
if (!segments.length) {
return;
}

const [firstSegment] = segments;

// no program date time
if (firstSegment.programDateTime === undefined) {
return;
}
// Set offset as ProgramDateTime for the very first segment of the very first playlist load:
this.offset_ = firstSegment.programDateTime / 1000;
}

setPendingDateRanges(dateRanges = []) {
if (!dateRanges.length) {
return;
}

const [dateRange] = dateRanges;
const startTime = dateRange.startDate.getTime();

this.trimProcessedDateRanges_(startTime);

this.pendingDateRanges_ = dateRanges.reduce((map, pendingDateRange) => {
map.set(pendingDateRange.id, pendingDateRange);
return map;
}, new Map());
}

processDateRange(dateRange) {
this.pendingDateRanges_.delete(dateRange.id);
this.processedDateRanges_.set(dateRange.id, dateRange);
}

getDateRangesToProcess() {
if (this.offset_ === null) {
return [];
}

const dateRangeClasses = {};
const dateRangesToProcess = [];

this.pendingDateRanges_.forEach((dateRange, id) => {
if (this.processedDateRanges_.has(id)) {
return;
}

dateRange.startTime = (dateRange.startDate.getTime() / 1000) - this.offset_;
dateRange.processDateRange = () => this.processDateRange(dateRange);
dateRangesToProcess.push(dateRange);

if (!dateRange.class) {
return;
}

if (dateRangeClasses[dateRange.class]) {
const length = dateRangeClasses[dateRange.class].push(dateRange);

dateRange.classListIndex = length - 1;
} else {
dateRangeClasses[dateRange.class] = [dateRange];
dateRange.classListIndex = 0;
}
});

for (const dateRange of dateRangesToProcess) {
const classList = dateRangeClasses[dateRange.class] || [];

if (dateRange.endDate) {
dateRange.endTime = (dateRange.endDate.getTime() / 1000) - this.offset_;
} else if (dateRange.endOnNext && classList[dateRange.classListIndex + 1]) {
dateRange.endTime = classList[dateRange.classListIndex + 1].startTime;
} else if (dateRange.duration) {
dateRange.endTime = dateRange.startTime + dateRange.duration;
} else if (dateRange.plannedDuration) {
dateRange.endTime = dateRange.startTime + dateRange.plannedDuration;
} else {
dateRange.endTime = dateRange.startTime;
}
}

return dateRangesToProcess;
}

trimProcessedDateRanges_(startTime) {
const copy = new Map(this.processedDateRanges_);

copy.forEach((dateRange, id) => {
if (dateRange.startDate.getTime() < startTime) {
this.processedDateRanges_.delete(id);
}
});
}
}
71 changes: 70 additions & 1 deletion src/util/text-tracks.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,72 @@ export const addMetadata = ({
});
};

// object for mapping daterange attributes
const dateRangeAttr = {
id: 'ID',
class: 'CLASS',
startDate: 'START-DATE',
duration: 'DURATION',
endDate: 'END-DATE',
endOnNext: 'END-ON-NEXT',
plannedDuration: 'PLANNED-DURATION',
scte35Out: 'SCTE35-OUT',
scte35In: 'SCTE35-IN'
};

const dateRangeKeysToOmit = new Set([
'id',
'class',
'startDate',
'duration',
'endDate',
'endOnNext',
'startTime',
'endTime',
'processDateRange'
]);

/**
* Add DateRange metadata text track to a source handler given an array of metadata
*
* @param {Object}
* @param {Object} inbandTextTracks the inband text tracks
* @param {Array} dateRanges parsed media playlist
* @private
*/
export const addDateRangeMetadata = ({ inbandTextTracks, dateRanges }) => {
const metadataTrack = inbandTextTracks.metadataTrack_;

if (!metadataTrack) {
return;
}

const Cue = window.WebKitDataCue || window.VTTCue;

dateRanges.forEach((dateRange) => {
// we generate multiple cues for each date range with different attributes
for (const key of Object.keys(dateRange)) {
if (dateRangeKeysToOmit.has(key)) {
continue;
}

const cue = new Cue(dateRange.startTime, dateRange.endTime, '');

cue.id = dateRange.id;
cue.type = 'com.apple.quicktime.HLS';
cue.value = { key: dateRangeAttr[key], data: dateRange[key] };

if (key === 'scte35Out' || key === 'scte35In') {
cue.value.data = new Uint8Array(cue.value.data.match(/[\da-f]{2}/gi)).buffer;
}

metadataTrack.addCue(cue);
}

dateRange.processDateRange();
});
};

/**
* Create metadata text track on video.js if it does not exist
*
Expand All @@ -241,7 +307,10 @@ export const createMetadataTrackIfNotExists = (inbandTextTracks, dispatchType, t
label: 'Timed Metadata'
}, false).track;

inbandTextTracks.metadataTrack_.inBandMetadataTrackDispatchType = dispatchType;
if (!videojs.browser.IS_ANY_SAFARI) {
inbandTextTracks.metadataTrack_.inBandMetadataTrackDispatchType = dispatchType;

}
};

/**
Expand Down
46 changes: 46 additions & 0 deletions test/playlist-controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,52 @@ QUnit.test('addMetadataToTextTrack adds expected metadata to the metadataTrack',
controller.dispose();
});

QUnit.test('addDateRangesToTextTrack adds expected metadata to the metadataTrack', function(assert) {
const options = {
src: 'manifest/daterange.m3u8',
tech: this.player.tech_,
sourceType: 'hls'
};
const controller = new PlaylistController(options);
const dateRanges = [{
endDate: new Date(5000),
endTime: 3,
plannedDuration: 5,
scte35Out: '0xFC30200FFF00F0500D00E4612424',
startDate: new Date(3000),
startTime: 1,
id: 'testId',
processDateRange: () => {}
}];
const expectedCueValues = [{
endTime: 3,
id: 'testId',
startTime: 1,
value: {data: 5, key: 'PLANNED-DURATION'}
}, {
endTime: 3,
id: 'testId',
startTime: 1,
value: {data: new ArrayBuffer(), key: 'SCTE35-OUT'}
}];

controller.mainPlaylistLoader_.addDateRangesToTextTrack_(dateRanges);
const actualCueValues = controller.inbandTextTracks_.metadataTrack_.cues_.map((cue)=>{
return {
startTime: cue.startTime,
endTime: cue.endTime,
id: cue.id,
value: {
data: cue.value.data,
key: cue.value.key
}
};
});

assert.deepEqual(actualCueValues, expectedCueValues, 'expected cue values are added to the metadataTrack');
controller.dispose();
});

QUnit.test('obeys metadata preload option', function(assert) {
this.player.preload('metadata');
// main
Expand Down
36 changes: 36 additions & 0 deletions test/playlist-loader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
parseManifest
} from '../src/manifest.js';
import manifests from 'create-test-data!manifests';
import sinon from 'sinon';

QUnit.module('Playlist Loader', function(hooks) {
hooks.beforeEach(function(assert) {
Expand Down Expand Up @@ -2850,4 +2851,39 @@ QUnit.module('Playlist Loader', function(hooks) {

assert.equal(this.requests[0].uri, 'http://example.com/media.m3u8?foo=test&_HLS_skip=YES&_HLS_msn=8&_HLS_part=1');
});

QUnit.module('DateRanges', {
beforeEach() {
this.fakeVhs = {
xhr: xhrFactory()
};
this.loader = new PlaylistLoader('http://example.com/media.m3u8', this.fakeVhs, {addDateRangesToTextTrack: () => {}});

this.loader.load();
},
afterEach() {
this.loader.dispose();
}
});

QUnit.test('addDateRangesToTextTrack called on loadedplaylist', function(assert) {
this.loader.media = () => {
return {
segments: [{
programDateTime: 2000,
duration: 1
}],
dateRanges: [{
startDate: new Date(2500),
endDate: new Date(3000),
plannedDuration: 40,
id: 'testId'
}]
};
};
const addDateRangesToTextTrackSpy = sinon.spy(this.loader, 'addDateRangesToTextTrack_');

this.loader.trigger('loadedplaylist');
assert.strictEqual(addDateRangesToTextTrackSpy.callCount, 1);
});
});
Loading

0 comments on commit 7c0e968

Please sign in to comment.