Skip to content

Commit

Permalink
feat: sync controller media sequence strategy (#1458)
Browse files Browse the repository at this point in the history
* feat: add media sequence sync strategy

* fix: fix current media sequence increment

* chore: update logs

* feat: use exact segment match in sync-controller

* fix: fix race condition for a fast quality switch

* chore: add additional logs for choose next request

* feat: force timestamp after resync

* chore: fix or skip tests

* Update src/segment-loader.js

Co-authored-by: Walter Seymour <walterseymour15@gmail.com>

---------

Co-authored-by: Dzianis Dashkevich <ddashkevich@brightcove.com>
Co-authored-by: Walter Seymour <walterseymour15@gmail.com>
  • Loading branch information
3 people authored Dec 4, 2023
1 parent 98dedca commit 6e6e6c4
Show file tree
Hide file tree
Showing 6 changed files with 232 additions and 13 deletions.
23 changes: 22 additions & 1 deletion src/playlist-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -681,9 +681,14 @@ export class PlaylistController extends videojs.EventTarget {
// that the segments have changed in some way and use that to
// update the SegmentLoader instead of doing it twice here and
// on `loadedplaylist`
this.mainSegmentLoader_.pause();
this.mainSegmentLoader_.playlist(media, this.requestOptions_);

this.mainSegmentLoader_.load();
if (this.waitingForFastQualityPlaylistReceived_) {
this.runFastQualitySwitch_();
} else {
this.mainSegmentLoader_.load();
}

this.tech_.trigger({
type: 'mediachange',
Expand Down Expand Up @@ -745,7 +750,12 @@ export class PlaylistController extends videojs.EventTarget {
// that the segments have changed in some way and use that to
// update the SegmentLoader instead of doing it twice here and
// on `mediachange`
this.mainSegmentLoader_.pause();
this.mainSegmentLoader_.playlist(updatedPlaylist, this.requestOptions_);
if (this.waitingForFastQualityPlaylistReceived_) {
this.runFastQualitySwitch_();
}

this.updateDuration(!updatedPlaylist.endList);

// If the player isn't paused, ensure that the segment loader is running,
Expand Down Expand Up @@ -974,12 +984,20 @@ export class PlaylistController extends videojs.EventTarget {

this.switchMedia_(media, 'fast-quality');

// we would like to avoid race condition when we call fastQuality,
// reset everything and start loading segments from prev segments instead of new because new playlist is not received yet
this.waitingForFastQualityPlaylistReceived_ = true;
}

runFastQualitySwitch_() {
this.waitingForFastQualityPlaylistReceived_ = false;
// Delete all buffered data to allow an immediate quality switch, then seek to give
// the browser a kick to remove any cached frames from the previous rendtion (.04 seconds
// ahead was roughly the minimum that will accomplish this across a variety of content
// in IE and Edge, but seeking in place is sufficient on all other browsers)
// Edge/IE bug: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14600375/
// Chrome bug: https://bugs.chromium.org/p/chromium/issues/detail?id=651904
this.mainSegmentLoader_.pause();
this.mainSegmentLoader_.resetEverything(() => {
this.tech_.setCurrentTime(this.tech_.currentTime());
});
Expand Down Expand Up @@ -1446,11 +1464,14 @@ export class PlaylistController extends videojs.EventTarget {

// cancel outstanding requests so we begin buffering at the new
// location
this.mainSegmentLoader_.pause();
this.mainSegmentLoader_.resetEverything();
if (this.mediaTypes_.AUDIO.activePlaylistLoader) {
this.audioSegmentLoader_.pause();
this.audioSegmentLoader_.resetEverything();
}
if (this.mediaTypes_.SUBTITLES.activePlaylistLoader) {
this.subtitleSegmentLoader_.pause();
this.subtitleSegmentLoader_.resetEverything();
}

Expand Down
15 changes: 14 additions & 1 deletion src/segment-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ export default class SegmentLoader extends videojs.EventTarget {
this.checkBufferTimeout_ = null;
this.error_ = void 0;
this.currentTimeline_ = -1;
this.shouldForceTimestampOffsetAfterResync_ = false;
this.pendingSegment_ = null;
this.xhrOptions_ = null;
this.pendingSegments_ = [];
Expand Down Expand Up @@ -1032,6 +1033,7 @@ export default class SegmentLoader extends videojs.EventTarget {
}

this.logger_(`playlist update [${oldId} => ${newPlaylist.id || newPlaylist.uri}]`);
this.syncController_.updateMediaSequenceMap(newPlaylist, this.currentTime_(), this.loaderType_);

// in VOD, this is always a rendition switch (or we updated our syncInfo above)
// in LIVE, we always want to update with new playlists (including refreshes)
Expand Down Expand Up @@ -1213,6 +1215,7 @@ export default class SegmentLoader extends videojs.EventTarget {
this.partIndex = null;
this.syncPoint_ = null;
this.isPendingTimestampOffset_ = false;
this.shouldForceTimestampOffsetAfterResync_ = true;
this.callQueue_ = [];
this.loadQueue_ = [];
this.metadataQueue_.id3 = [];
Expand Down Expand Up @@ -1420,7 +1423,8 @@ export default class SegmentLoader extends videojs.EventTarget {
this.playlist_,
this.duration_(),
this.currentTimeline_,
this.currentTime_()
this.currentTime_(),
this.loaderType_
);

const next = {
Expand All @@ -1433,6 +1437,7 @@ export default class SegmentLoader extends videojs.EventTarget {

if (next.isSyncRequest) {
next.mediaIndex = getSyncSegmentCandidate(this.currentTimeline_, segments, bufferedEnd);
this.logger_(`choose next request. Can not find sync point. Fallback to media Index: ${next.mediaIndex}`);
} else if (this.mediaIndex !== null) {
const segment = segments[this.mediaIndex];
const partIndex = typeof this.partIndex === 'number' ? this.partIndex : -1;
Expand Down Expand Up @@ -1461,6 +1466,8 @@ export default class SegmentLoader extends videojs.EventTarget {
next.mediaIndex = segmentIndex;
next.startOfSegment = startTime;
next.partIndex = partIndex;

this.logger_(`choose next request. Playlist switched and we have a sync point. Media Index: ${next.mediaIndex} `);
}

const nextSegment = segments[next.mediaIndex];
Expand Down Expand Up @@ -1519,6 +1526,12 @@ export default class SegmentLoader extends videojs.EventTarget {
return null;
}

if (this.shouldForceTimestampOffsetAfterResync_) {
this.shouldForceTimestampOffsetAfterResync_ = false;
next.forceTimestampOffset = true;
this.logger_('choose next request. Force timestamp offset after loader resync');
}

return this.generateSegmentInfo_(next);
}

Expand Down
193 changes: 188 additions & 5 deletions src/sync-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,86 @@ export const syncPointStrategies = [
return null;
}
},
{
name: 'MediaSequence',
/**
* run media sequence strategy
*
* @param {SyncController} syncController
* @param {Object} playlist
* @param {number} duration
* @param {number} currentTimeline
* @param {number} currentTime
* @param {string} type
*/
run: (syncController, playlist, duration, currentTimeline, currentTime, type) => {
if (!type) {
return null;
}

const mediaSequenceMap = syncController.getMediaSequenceMap(type);

if (!mediaSequenceMap || mediaSequenceMap.size === 0) {
return null;
}

if (playlist.mediaSequence === undefined || !Array.isArray(playlist.segments) || !playlist.segments.length) {
return null;
}

let currentMediaSequence = playlist.mediaSequence;
let segmentIndex = 0;

for (const segment of playlist.segments) {
const range = mediaSequenceMap.get(currentMediaSequence);

if (!range) {
// unexpected case
// we expect this playlist to be the same playlist in the map
// just break from the loop and move forward to the next strategy
break;
}

if (currentTime >= range.start && currentTime < range.end) {
// we found segment

if (Array.isArray(segment.parts) && segment.parts.length) {
let currentPartStart = range.start;
let partIndex = 0;

for (const part of segment.parts) {
const start = currentPartStart;
const end = start + part.duration;

if (currentTime >= start && currentTime < end) {
return {
time: range.start,
segmentIndex,
partIndex
};
}

partIndex++;
currentPartStart = end;
}
}

// no parts found, return sync point for segment
return {
time: range.start,
segmentIndex,
partIndex: null
};
}

segmentIndex++;
currentMediaSequence++;
}

// we didn't find any segments for provided current time
return null;
}
},
// Stategy "ProgramDateTime": We have a program-date-time tag in this playlist
{
name: 'ProgramDateTime',
Expand Down Expand Up @@ -193,9 +273,79 @@ export default class SyncController extends videojs.EventTarget {
this.discontinuities = [];
this.timelineToDatetimeMappings = {};

/**
* @type {Map<string, Map<number, { start: number, end: number }>>}
* @private
*/
this.mediaSequenceStorage_ = new Map();

this.logger_ = logger('SyncController');
}

/**
* Get media sequence map by type
*
* @param {string} type - segment loader type
* @return {Map<number, { start: number, end: number }> | undefined}
*/
getMediaSequenceMap(type) {
return this.mediaSequenceStorage_.get(type);
}

/**
* Update Media Sequence Map -> <MediaSequence, Range>
*
* @param {Object} playlist - parsed playlist
* @param {number} currentTime - current player's time
* @param {string} type - segment loader type
* @return {void}
*/
updateMediaSequenceMap(playlist, currentTime, type) {
// we should not process this playlist if it does not have mediaSequence or segments
if (playlist.mediaSequence === undefined || !Array.isArray(playlist.segments) || !playlist.segments.length) {
return;
}

const currentMap = this.getMediaSequenceMap(type);
const result = new Map();

let currentMediaSequence = playlist.mediaSequence;
let currentBaseTime;

if (!currentMap) {
// first playlist setup:
currentBaseTime = 0;
} else if (currentMap.has(playlist.mediaSequence)) {
// further playlists setup:
currentBaseTime = currentMap.get(playlist.mediaSequence).start;
} else {
// it seems like we have a gap between playlists, use current time as a fallback:
this.logger_(`MediaSequence sync for ${type} segment loader - received a gap between playlists.
Fallback base time to: ${currentTime}.
Received media sequence: ${currentMediaSequence}.
Current map: `, currentMap);
currentBaseTime = currentTime;
}

this.logger_(`MediaSequence sync for ${type} segment loader.
Received media sequence: ${currentMediaSequence}.
base time is ${currentBaseTime}
Current map: `, currentMap);

playlist.segments.forEach((segment) => {
const start = currentBaseTime;
const end = start + segment.duration;
const range = { start, end };

result.set(currentMediaSequence, range);

currentMediaSequence++;
currentBaseTime = end;
});

this.mediaSequenceStorage_.set(type, result);
}

/**
* Find a sync-point for the playlist specified
*
Expand All @@ -208,10 +358,14 @@ export default class SyncController extends videojs.EventTarget {
* Duration of the MediaSource (Infinite if playing a live source)
* @param {number} currentTimeline
* The last timeline from which a segment was loaded
* @param {number} currentTime
* Current player's time
* @param {string} type
* Segment loader type
* @return {Object}
* A sync-point object
*/
getSyncPoint(playlist, duration, currentTimeline, currentTime) {
getSyncPoint(playlist, duration, currentTimeline, currentTime, type) {
// Always use VOD sync point for VOD
if (duration !== Infinity) {
const vodSyncPointStrategy = syncPointStrategies.find(({ name }) => name === 'VOD');
Expand All @@ -223,7 +377,8 @@ export default class SyncController extends videojs.EventTarget {
playlist,
duration,
currentTimeline,
currentTime
currentTime,
type
);

if (!syncPoints.length) {
Expand All @@ -233,6 +388,28 @@ export default class SyncController extends videojs.EventTarget {
return null;
}

// If we have exact match just return it instead of finding the nearest distance
for (const syncPointInfo of syncPoints) {
const { syncPoint, strategy } = syncPointInfo;
const { segmentIndex, time } = syncPoint;

if (segmentIndex < 0) {
continue;
}

const selectedSegment = playlist.segments[segmentIndex];

const start = time;
const end = start + selectedSegment.duration;

this.logger_(`Strategy: ${strategy}. Current time: ${currentTime}. selected segment: ${segmentIndex}. Time: [${start} -> ${end}]}`);

if (currentTime >= start && currentTime < end) {
this.logger_('Found sync point with exact match: ', syncPoint);
return syncPoint;
}
}

// Now find the sync-point that is closest to the currentTime because
// that should result in the most accurate guess about which segment
// to fetch
Expand All @@ -259,7 +436,8 @@ export default class SyncController extends videojs.EventTarget {
playlist,
duration,
playlist.discontinuitySequence,
0
0,
'main'
);

// Without sync-points, there is not enough information to determine the expired time
Expand Down Expand Up @@ -297,10 +475,14 @@ export default class SyncController extends videojs.EventTarget {
* Duration of the MediaSource (Infinity if playing a live source)
* @param {number} currentTimeline
* The last timeline from which a segment was loaded
* @param {number} currentTime
* Current player's time
* @param {string} type
* Segment loader type
* @return {Array}
* A list of sync-point objects
*/
runStrategies_(playlist, duration, currentTimeline, currentTime) {
runStrategies_(playlist, duration, currentTimeline, currentTime, type) {
const syncPoints = [];

// Try to find a sync-point in by utilizing various strategies...
Expand All @@ -311,7 +493,8 @@ export default class SyncController extends videojs.EventTarget {
playlist,
duration,
currentTimeline,
currentTime
currentTime,
type
);

if (syncPoint) {
Expand Down
Loading

0 comments on commit 6e6e6c4

Please sign in to comment.