import assert from "../../../utils/assert"; import type { IHTMLCue, ICuesGroup } from "./utils"; import { areNearlyEqual, getCuesAfter, getCuesBefore, removeCuesInfosBetween, areCuesStartNearlyEqual, } from "./utils"; /** * first or last IHTMLCue in a group can have a slighlty different start * or end time than the start or end time of the ICuesGroup due to parsing * approximation. * DELTA_CUES_GROUP defines the tolerance level when comparing the start/end * of a IHTMLCue to the start/end of a ICuesGroup. * Having this value too high may lead to have unwanted subtitle displayed * Having this value too low may lead to have subtitles not displayed */ const DELTA_CUES_GROUP = 1e-3; /** * segment_duration / RELATIVE_DELTA_RATIO = relative_delta * * relative_delta is the tolerance to determine if two segements are the same */ const RELATIVE_DELTA_RATIO = 5; /** * Manage a buffer of text track cues. * Allows to add, remove and recuperate cues at given times. * @class TextTrackCuesStore */ export default class TextTrackCuesStore { private _cuesBuffer: ICuesGroup[]; constructor() { this._cuesBuffer = []; } public isEmpty(): boolean { return this._cuesBuffer.length === 0; } /** * Get corresponding cue(s) for the given time. * A cue is an object with three properties: * - start {Number}: start time for which the cue should be displayed. * - end {Number}: end time for which the cue should be displayed. * - element {HTMLElement}: The cue to diplay * * We do not mutate individual cue here. * That is, if the ``get`` method returns the same cue's reference than a * previous ``get`` call, its properties are guaranteed to have the exact same * values than before, if you did not mutate it on your side. * The inverse is true, if the values are the same than before, the reference * will stay the same (this is useful to easily check if the DOM should be * updated, for example). * * @param {Number} time * @returns {Array.} - The cues that need to be displayed at that * time. */ get(time: number): HTMLElement[] { const cuesBuffer = this._cuesBuffer; const ret = []; // begins at the end as most of the time the player will ask for the last // CuesGroup for (let cueIdx = cuesBuffer.length - 1; cueIdx >= 0; cueIdx--) { const segment = cuesBuffer[cueIdx]; if (time < segment.end && time >= segment.start) { const cues = segment.cues; for (const cue of cues) { if (time >= cue.start && time < cue.end) { ret.push(cue.element); } } // first or last IHTMLCue in a group can have a slighlty different start // or end time than the start or end time of the ICuesGroup due to parsing // approximation. // Add a tolerance of 1ms to fix this issue if (ret.length === 0 && cues.length > 0) { for (const cue of cues) { if ( areNearlyEqual(time, cue.start, DELTA_CUES_GROUP) || areNearlyEqual(time, cue.end, DELTA_CUES_GROUP) ) { ret.push(cue.element); } } } return ret; } } return []; } /** * Remove cue from a certain range of time. * @param {Number} from * @param {Number} _to */ remove(from: number, _to: number): void { assert(from >= 0); assert(_to >= 0); assert(_to > from); const to = Math.max(from, _to); const cuesBuffer = this._cuesBuffer; for (let cueIdx = 0; cueIdx < cuesBuffer.length; cueIdx++) { if (cuesBuffer[cueIdx].end > from) { // this cuesInfos is concerned by the remove const startCuesInfos = cuesBuffer[cueIdx]; if (startCuesInfos.start >= to) { // our cuesInfos is strictly after this interval, we have nothing to do return; } if (startCuesInfos.end >= to) { // our cuesInfos ends after `to`, we have to keep the end of it if (from <= startCuesInfos.start) { // from -> to only remove the start of startCuesInfos startCuesInfos.cues = getCuesAfter(startCuesInfos.cues, to); startCuesInfos.start = to; } else { // from -> to is in the middle part of startCuesInfos const [cuesInfos1, cuesInfos2] = removeCuesInfosBetween( startCuesInfos, from, to, ); this._cuesBuffer[cueIdx] = cuesInfos1; cuesBuffer.splice(cueIdx + 1, 0, cuesInfos2); } // No cuesInfos can be concerned after this one, we can quit return; } // Else remove all part after `from` if (startCuesInfos.start >= from) { // all the segment is concerned cuesBuffer.splice(cueIdx, 1); cueIdx--; // one less element, we have to decrement the loop } else { // only the end is concerned startCuesInfos.cues = getCuesBefore(startCuesInfos.cues, from); startCuesInfos.end = Math.max(from, startCuesInfos.start); } } } } /** * Insert new cues in our text buffer. * cues is an array of objects with three properties: * - start {Number}: start time for which the cue should be displayed. * - end {Number}: end time for which the cue should be displayed. * - element {HTMLElement}: The cue to diplay * * @param {Array.} cues - CuesGroups, array of objects with the * following properties: * - start {Number}: the time at which the cue will start to be displayed * - end {Number}: the time at which the cue will end to be displayed * - cue {HTMLElement}: The cue * @param {Number} start - Start time at which the CuesGroup applies. * This is different than the start of the first cue to display in it, this * has more to do with the time at which the _text segment_ starts. * @param {Number} end - End time at which the CuesGroup applies. * This is different than the end of the last cue to display in it, this * has more to do with the time at which the _text segment_ ends. * * TODO add securities to ensure that: * - the start of a CuesGroup is inferior or equal to the start of the first * cue in it * - the end of a CuesGroup is superior or equal to the end of the last * cue in it * If those requirements are not met, we could delete some cues when adding * a CuesGroup before/after. Find a solution. */ insert(cues: IHTMLCue[], start: number, end: number): void { const cuesBuffer = this._cuesBuffer; const cuesInfosToInsert = { start, end, cues }; // it's preferable to have a delta depending on the duration of the segment // if the delta is one fifth of the length of the segment: // a segment of [0, 2] is the "same" segment as [0, 2.1] // but [0, 0.04] is not the "same" segement as [0,04, 0.08] const relativeDelta = Math.abs(start - end) / RELATIVE_DELTA_RATIO; /** * Called when we found the index of the next cue relative to the cue we * want to insert (that is a cue starting after its start or at the same * time but ending strictly after its end). * Will insert the cue at the right place and update the next cue * accordingly. * @param {number} indexOfNextCue */ function onIndexOfNextCueFound(indexOfNextCue: number): void { const nextCue = cuesBuffer[indexOfNextCue]; if ( nextCue === undefined || // no cue areNearlyEqual(cuesInfosToInsert.end, nextCue.end, relativeDelta) ) { // samey end // ours: |AAAAA| // the current one: |BBBBB| // Result: |AAAAA| cuesBuffer[indexOfNextCue] = cuesInfosToInsert; } else if (nextCue.start >= cuesInfosToInsert.end) { // Either // ours: |AAAAA| // the current one: |BBBBBB| // Result: |AAAAA| |BBBBBB| // Or: // ours: |AAAAA| // the current one: |BBBBBB| // Result: |AAAAA|BBBBBB| // Add ours before cuesBuffer.splice(indexOfNextCue, 0, cuesInfosToInsert); } else { // Either // ours: |AAAAA| // the current one: |BBBBBBBB| // Result: |AAAAABBB| // Or: // ours: |AAAAA| // the current one: |BBBBB| // Result: |AAAAABBB| nextCue.cues = getCuesAfter(nextCue.cues, cuesInfosToInsert.end); nextCue.start = cuesInfosToInsert.end; cuesBuffer.splice(indexOfNextCue, 0, cuesInfosToInsert); } } for (let cueIdx = 0; cueIdx < cuesBuffer.length; cueIdx++) { let cuesInfos = cuesBuffer[cueIdx]; if (start < cuesInfos.end) { if (areCuesStartNearlyEqual(cuesInfosToInsert, cuesInfos)) { if (areNearlyEqual(end, cuesInfos.end, relativeDelta)) { // exact same segment // ours: |AAAAA| // the current one: |BBBBB| // Result: |AAAAA| // Which means: // 1. replace the current cue with ours cuesBuffer[cueIdx] = cuesInfosToInsert; return; } else if (end < cuesInfos.end) { // our cue overlaps with the current one: // ours: |AAAAA| // the current one: |BBBBBBBB| // Result: |AAAAABBB| // Which means: // 1. remove some cues at the start of the current one // 2. update start of current one // 3. add ours before the current one cuesInfos.cues = getCuesAfter(cuesInfos.cues, end); cuesInfos.start = end; cuesBuffer.splice(cueIdx, 0, cuesInfosToInsert); return; } // our cue goes beyond the current one: // ours: |AAAAAAA| // the current one: |BBBB|... // Result: |AAAAAAA| // Here we have to delete any cuesInfos which end before ours end, // and see about the following one. do { cuesBuffer.splice(cueIdx, 1); cuesInfos = cuesBuffer[cueIdx]; } while (cuesInfos !== undefined && end > cuesInfos.end); onIndexOfNextCueFound(cueIdx); return; } else if (start < cuesInfos.start) { if (end < cuesInfos.start) { // our cue goes strictly before the current one: // ours: |AAAAAAA| // the current one: |BBBB| // Result: |AAAAAAA| |BBBB| // Which means: // - add ours before the current one cuesBuffer.splice(cueIdx, 0, cuesInfosToInsert); return; } else if (areNearlyEqual(end, cuesInfos.start, relativeDelta)) { // our cue goes just before the current one: // ours: |AAAAAAA| // the current one: |BBBB| // Result: |AAAAAAA|BBBB| // Which means: // - update start time of the current one to be sure // - add ours before the current one cuesInfos.start = end; cuesBuffer.splice(cueIdx, 0, cuesInfosToInsert); return; } else if (areNearlyEqual(end, cuesInfos.end, relativeDelta)) { // ours: |AAAAAAA| // the current one: |BBBB| // Result: |AAAAAAA| // Replace cuesBuffer.splice(cueIdx, 1, cuesInfosToInsert); return; } else if (end < cuesInfos.end) { // ours: |AAAAAAA| // the current one: |BBBBB| // Result: |AAAAAAABB| cuesInfos.cues = getCuesAfter(cuesInfos.cues, end); cuesInfos.start = end; cuesBuffer.splice(cueIdx, 0, cuesInfosToInsert); return; } // ours: |AAAAAAA| // the current one: |BBB|... // Result: |AAAAAAA|... do { cuesBuffer.splice(cueIdx, 1); cuesInfos = cuesBuffer[cueIdx]; } while (cuesInfos !== undefined && end > cuesInfos.end); onIndexOfNextCueFound(cueIdx); return; } // else -> start > cuesInfos.start if (areNearlyEqual(cuesInfos.end, end, relativeDelta)) { // ours: |AAAAAA| // the current one: |BBBBBBBB| // Result: |BBAAAAAA| cuesInfos.cues = getCuesBefore(cuesInfos.cues, start); cuesInfos.end = start; cuesBuffer.splice(cueIdx + 1, 0, cuesInfosToInsert); return; } else if (cuesInfos.end > end) { // ours: |AAAAAA| // the current one: |BBBBBBBBBBB| // Result: |BBAAAAAABBB| const [cuesInfos1, cuesInfos2] = removeCuesInfosBetween(cuesInfos, start, end); this._cuesBuffer[cueIdx] = cuesInfos1; cuesBuffer.splice(cueIdx + 1, 0, cuesInfosToInsert); cuesBuffer.splice(cueIdx + 2, 0, cuesInfos2); return; } else { // ours: |AAAAAA| // the current one: |BBBBB|... // Result: |BBAAAAAA|... cuesInfos.cues = getCuesBefore(cuesInfos.cues, start); cuesInfos.end = start; const nextCueIdx = cueIdx + 1; cuesInfos = cuesBuffer[nextCueIdx]; while (cuesInfos !== undefined && end > cuesInfos.end) { cuesBuffer.splice(nextCueIdx, 1); cuesInfos = cuesBuffer[nextCueIdx]; } onIndexOfNextCueFound(nextCueIdx); return; } } } if (cuesBuffer.length > 0) { const lastCue = cuesBuffer[cuesBuffer.length - 1]; if (areNearlyEqual(lastCue.end, start, relativeDelta)) { // Match the end of the previous cue to the start of the following one // if they are close enough. If there is a small gap between two segments // it can lead to having no subtitles for a short time, this is noticeable when // two successive segments displays the same text, making it diseappear // and reappear quickly, which gives the impression of blinking // // ours: |AAAAA| // the current one: |BBBBB|... // Result: |BBBBBBBAAAAA| lastCue.end = start; } } // no cues group has the end after our current start. // These cues should be the last one cuesBuffer.push(cuesInfosToInsert); } }