rx-player
Version:
Canal+ HTML5 Video Player
434 lines (390 loc) • 13.9 kB
text/typescript
import type { IMediaElement } from "../../../compat/browser_compatibility_types";
import { onEnded, onSeeked, onSeeking } from "../../../compat/event_listeners";
import onHeightWidthChange from "../../../compat/on_height_width_change";
import config from "../../../config";
import log from "../../../log";
import type { ITextTrackSegmentData } from "../../../transports";
import type { IRange } from "../../../utils/ranges";
import { convertToRanges } from "../../../utils/ranges";
import type { CancellationSignal } from "../../../utils/task_canceller";
import TaskCanceller from "../../../utils/task_canceller";
import ManualTimeRanges from "../manual_time_ranges";
import type { ITextDisplayer, ITextDisplayerData } from "../types";
import parseTextTrackToElements from "./html_parsers";
import TextTrackCuesStore from "./text_track_cues_store";
import updateProportionalElements from "./update_proportional_elements";
/**
* @param {Element} element
* @param {Element} child
*/
function safelyRemoveChild(element: Element, child: Element) {
try {
element.removeChild(child);
} catch (_error) {
log.warn("HTD: Can't remove text track: not in the element.");
}
}
/**
* @param {HTMLElement} element
* @returns {Object|null}
*/
function getElementResolution(
element: HTMLElement,
): { rows: number; columns: number } | null {
const strRows = element.getAttribute("data-resolution-rows");
const strColumns = element.getAttribute("data-resolution-columns");
if (strRows === null || strColumns === null) {
return null;
}
const rows = parseInt(strRows, 10);
const columns = parseInt(strColumns, 10);
if (rows === null || columns === null) {
return null;
}
return { rows, columns };
}
/**
* TextDisplayer implementation which display buffered TextTracks in the given
* HTML element.
* @class HTMLTextDisplayer
*/
export default class HTMLTextDisplayer implements ITextDisplayer {
/**
* The video element the cues refer to.
* Used to know when the user is seeking, for example.
*/
private readonly _videoElement: IMediaElement;
/** Allows to cancel the interval at which subtitles are updated. */
private _subtitlesIntervalCanceller: TaskCanceller;
/** HTMLElement which will contain the cues */
private readonly _textTrackElement: HTMLElement;
/** Buffer containing the data */
private readonly _buffer: TextTrackCuesStore;
/**
* We could need us to automatically update styling depending on
* `_textTrackElement`'s size. This TaskCanceller allows to stop that
* regular check.
*/
private _sizeUpdateCanceller: TaskCanceller;
/** Information on cues currently displayed. */
private _currentCues: Array<{
/** The HTMLElement containing the cues, appended to `_textTrackElement`. */
element: HTMLElement;
/**
* Announced resolution for this element.
* Necessary to properly render proportional sizes.
*/
resolution: { columns: number; rows: number } | null;
}>;
/** TimeRanges implementation for this buffer. */
private _buffered: ManualTimeRanges;
/**
* If `true`, we're currently automatically refreshing subtitles in intervals
* (and on some playback events) based on the polled current position.
*
* TODO link it to `_subtitlesIntervalCanceller`? Or just use
* `_subtitlesIntervalCanceller.isUsed`? To check.
*/
private _isAutoRefreshing: boolean;
/**
* @param {HTMLMediaElement} videoElement
* @param {HTMLElement} textTrackElement
*/
constructor(videoElement: IMediaElement, textTrackElement: HTMLElement) {
log.debug("HTD: Creating HTMLTextDisplayer");
this._buffered = new ManualTimeRanges();
this._videoElement = videoElement;
this._textTrackElement = textTrackElement;
this._sizeUpdateCanceller = new TaskCanceller();
this._subtitlesIntervalCanceller = new TaskCanceller();
this._buffer = new TextTrackCuesStore();
this._currentCues = [];
this._isAutoRefreshing = false;
}
/**
* Push text segment to the HTMLTextDisplayer.
* @param {Object} infos
* @returns {Object}
*/
public pushTextData(infos: ITextDisplayerData): IRange[] {
log.debug("HTD: Appending new html text tracks");
const { timestampOffset, appendWindow, chunk } = infos;
if (chunk === null) {
return convertToRanges(this._buffered);
}
const { start: startTime, end: endTime, data: dataString, type, language } = chunk;
const appendWindowStart = appendWindow[0] ?? 0;
const appendWindowEnd = appendWindow[1] ?? Infinity;
const cues = parseTextTrackToElements(type, dataString, timestampOffset, language);
if (appendWindowStart !== 0 && appendWindowEnd !== Infinity) {
// Removing before window start
let i = 0;
while (i < cues.length && cues[i].end <= appendWindowStart) {
i++;
}
cues.splice(0, i);
i = 0;
while (i < cues.length && cues[i].start < appendWindowStart) {
cues[i].start = appendWindowStart;
i++;
}
// Removing after window end
i = cues.length - 1;
while (i >= 0 && cues[i].start >= appendWindowEnd) {
i--;
}
cues.splice(i, cues.length);
i = cues.length - 1;
while (i >= 0 && cues[i].end > appendWindowEnd) {
cues[i].end = appendWindowEnd;
i--;
}
}
let start: number;
if (startTime !== undefined) {
start = Math.max(appendWindowStart, startTime);
} else {
if (cues.length <= 0) {
log.warn("HTD: Current text tracks have no cues nor start time. Aborting");
return convertToRanges(this._buffered);
}
log.warn("HTD: No start time given. Guessing from cues.");
start = cues[0].start;
}
let end: number;
if (endTime !== undefined) {
end = Math.min(appendWindowEnd, endTime);
} else {
if (cues.length <= 0) {
log.warn("HTD: Current text tracks have no cues nor end time. Aborting");
return convertToRanges(this._buffered);
}
log.warn("HTD: No end time given. Guessing from cues.");
end = cues[cues.length - 1].end;
}
if (end <= start) {
log.warn(
"HTD: Invalid text track appended: ",
"the start time is inferior or equal to the end time.",
);
return convertToRanges(this._buffered);
}
this._buffer.insert(cues, start, end);
this._buffered.insert(start, end);
if (!this._isAutoRefreshing && !this._buffer.isEmpty()) {
this.autoRefreshSubtitles(this._subtitlesIntervalCanceller.signal);
}
return convertToRanges(this._buffered);
}
/**
* Remove buffered data.
* @param {number} start - start position, in seconds
* @param {number} end - end position, in seconds
* @returns {Object}
*/
public removeBuffer(start: number, end: number): IRange[] {
log.debug("HTD: Removing html text track data", start, end);
this._buffer.remove(start, end);
this._buffered.remove(start, end);
if (this._isAutoRefreshing && this._buffer.isEmpty()) {
this.refreshSubtitles();
this._isAutoRefreshing = false;
this._subtitlesIntervalCanceller.cancel();
this._subtitlesIntervalCanceller = new TaskCanceller();
}
return convertToRanges(this._buffered);
}
/**
* Returns the currently buffered data, in a TimeRanges object.
* @returns {TimeRanges}
*/
public getBufferedRanges(): IRange[] {
return convertToRanges(this._buffered);
}
public reset(): void {
log.debug("HTD: Resetting HTMLTextDisplayer");
this.stop();
this._subtitlesIntervalCanceller = new TaskCanceller();
}
public stop(): void {
if (this._subtitlesIntervalCanceller.isUsed()) {
return;
}
log.debug("HTD: Stopping HTMLTextDisplayer");
this._disableCurrentCues();
this._buffer.remove(0, Infinity);
this._buffered.remove(0, Infinity);
this._isAutoRefreshing = false;
this._subtitlesIntervalCanceller.cancel();
}
/**
* Remove the current cue from being displayed.
*/
private _disableCurrentCues(): void {
this._sizeUpdateCanceller.cancel();
if (this._currentCues.length > 0) {
for (const cue of this._currentCues) {
safelyRemoveChild(this._textTrackElement, cue.element);
}
this._currentCues = [];
}
}
/**
* Display a new Cue. If one was already present, it will be replaced.
* @param {HTMLElement} elements
*/
private _displayCues(elements: HTMLElement[]): void {
const nothingChanged =
this._currentCues.length === elements.length &&
this._currentCues.every((current, index) => current.element === elements[index]);
if (nothingChanged) {
return;
}
// Remove and re-display everything
// TODO More intelligent handling
this._sizeUpdateCanceller.cancel();
for (const cue of this._currentCues) {
safelyRemoveChild(this._textTrackElement, cue.element);
}
this._currentCues = [];
for (const element of elements) {
const resolution = getElementResolution(element);
this._currentCues.push({ element, resolution });
this._textTrackElement.appendChild(element);
}
const proportionalCues = this._currentCues.filter(
(
cue,
): cue is {
resolution: { rows: number; columns: number };
element: HTMLElement;
} => cue.resolution !== null,
);
if (proportionalCues.length > 0) {
this._sizeUpdateCanceller = new TaskCanceller();
this._sizeUpdateCanceller.linkToSignal(this._subtitlesIntervalCanceller.signal);
const { TEXT_TRACK_SIZE_CHECKS_INTERVAL } = config.getCurrent();
// update propertionally-sized elements periodically
const heightWidthRef = onHeightWidthChange(
this._textTrackElement,
TEXT_TRACK_SIZE_CHECKS_INTERVAL,
this._sizeUpdateCanceller.signal,
);
heightWidthRef.onUpdate(
({ height, width }) => {
for (const cue of proportionalCues) {
const { resolution, element } = cue;
updateProportionalElements(height, width, resolution, element);
}
},
{
clearSignal: this._sizeUpdateCanceller.signal,
emitCurrentValue: true,
},
);
}
}
/**
* Auto-refresh the display of subtitles according to the media element's
* position and events.
* @param {Object} cancellationSignal
*/
private autoRefreshSubtitles(cancellationSignal: CancellationSignal): void {
if (this._isAutoRefreshing || cancellationSignal.isCancelled()) {
return;
}
let autoRefreshCanceller: TaskCanceller | null = null;
const { MAXIMUM_HTML_TEXT_TRACK_UPDATE_INTERVAL } = config.getCurrent();
const stopAutoRefresh = () => {
this._isAutoRefreshing = false;
if (autoRefreshCanceller !== null) {
autoRefreshCanceller.cancel();
autoRefreshCanceller = null;
}
};
const startAutoRefresh = () => {
stopAutoRefresh();
this._isAutoRefreshing = true;
autoRefreshCanceller = new TaskCanceller();
autoRefreshCanceller.linkToSignal(cancellationSignal);
const intervalId = setInterval(
() => this.refreshSubtitles(),
MAXIMUM_HTML_TEXT_TRACK_UPDATE_INTERVAL,
);
autoRefreshCanceller.signal.register(() => {
clearInterval(intervalId);
});
this.refreshSubtitles();
};
onSeeking(
this._videoElement,
() => {
stopAutoRefresh();
this._disableCurrentCues();
},
cancellationSignal,
);
onSeeked(this._videoElement, startAutoRefresh, cancellationSignal);
onEnded(this._videoElement, startAutoRefresh, cancellationSignal);
startAutoRefresh();
}
/**
* Refresh current subtitles according to the current media element's
* position.
*/
private refreshSubtitles(): void {
const { MAXIMUM_HTML_TEXT_TRACK_UPDATE_INTERVAL } = config.getCurrent();
let time;
if (this._videoElement.paused || this._videoElement.playbackRate <= 0) {
time = this._videoElement.currentTime;
} else {
// to spread the time error, we divide the regular chosen interval.
time = Math.max(
this._videoElement.currentTime +
MAXIMUM_HTML_TEXT_TRACK_UPDATE_INTERVAL / 1000 / 2,
0,
);
}
const cues = this._buffer.get(time);
if (cues.length === 0) {
this._disableCurrentCues();
} else {
this._displayCues(cues);
}
}
}
/** Data of chunks that should be pushed to the `HTMLTextDisplayer`. */
export interface ITextTracksBufferSegmentData {
/** The text track data, in the format indicated in `type`. */
data: string;
/** The format of `data` (examples: "ttml", "srt" or "vtt") */
type: string;
/**
* Language in which the text track is, as a language code.
* This is mostly needed for "sami" subtitles, to know which cues can / should
* be parsed.
*/
language?: string | undefined;
/** start time from which the segment apply, in seconds. */
start?: number | undefined;
/** end time until which the segment apply, in seconds. */
end?: number | undefined;
}
/*
* The following ugly code is here to provide a compile-time check that an
* `IHTMLTextTracksBufferSegmentData` (type of data pushed to a
* `HTMLTextDisplayer`) can be derived from a `ITextTrackSegmentData`
* (text track data parsed from a segment).
*
* It doesn't correspond at all to real code that will be called. This is just
* a hack to tell TypeScript to perform that check.
*/
if ((__ENVIRONMENT__.CURRENT_ENV as number) === (__ENVIRONMENT__.DEV as number)) {
// @ts-expect-error: uncalled function just for type-checking
function _checkType(input: ITextTrackSegmentData): void {
function checkEqual(_arg: ITextTracksBufferSegmentData): void {
/* nothing */
}
checkEqual(input);
}
}