src/controller/id3-track-controller.ts
import { Events } from '../events';
import {
sendAddTrackEvent,
clearCurrentCues,
removeCuesInRange,
} from '../utils/texttrack-utils';
import * as ID3 from '../demux/id3';
import { DateRange, DateRangeAttribute } from '../loader/date-range';
import { MetadataSchema } from '../types/demuxer';
import type {
BufferFlushingData,
FragParsingMetadataData,
LevelUpdatedData,
MediaAttachedData,
} from '../types/events';
import type { ComponentAPI } from '../types/component-api';
import type Hls from '../hls';
declare global {
interface Window {
WebKitDataCue: VTTCue | void;
}
}
type Cue = VTTCue | TextTrackCue;
const MIN_CUE_DURATION = 0.25;
function getCueClass() {
// Attempt to recreate Safari functionality by creating
// WebKitDataCue objects when available and store the decoded
// ID3 data in the value property of the cue
return (self.WebKitDataCue || self.VTTCue || self.TextTrackCue) as any;
}
function dateRangeDateToTimelineSeconds(date: Date, offset: number): number {
return date.getTime() / 1000 - offset;
}
function hexToArrayBuffer(str): ArrayBuffer {
return Uint8Array.from(
str
.replace(/^0x/, '')
.replace(/([\da-fA-F]{2}) ?/g, '0x$1 ')
.replace(/ +$/, '')
.split(' ')
).buffer;
}
class ID3TrackController implements ComponentAPI {
private hls: Hls;
private id3Track: TextTrack | null = null;
private media: HTMLMediaElement | null = null;
private dateRangeCuesAppended: Record<
string,
{ cues: Record<string, Cue>; dateRange: DateRange; durationKnown: boolean }
> = {};
constructor(hls) {
this.hls = hls;
this._registerListeners();
}
destroy() {
this._unregisterListeners();
this.id3Track = null;
this.media = null;
this.dateRangeCuesAppended = {};
// @ts-ignore
this.hls = null;
}
private _registerListeners() {
const { hls } = this;
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this);
hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
}
private _unregisterListeners() {
const { hls } = this;
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this);
hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
}
// Add ID3 metatadata text track.
protected onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData
): void {
this.media = data.media;
}
protected onMediaDetaching(): void {
if (!this.id3Track) {
return;
}
clearCurrentCues(this.id3Track);
this.id3Track = null;
this.media = null;
this.dateRangeCuesAppended = {};
}
private onManifestLoading() {
this.dateRangeCuesAppended = {};
}
createTrack(media: HTMLMediaElement): TextTrack {
const track = this.getID3Track(media.textTracks) as TextTrack;
track.mode = 'hidden';
return track;
}
getID3Track(textTracks: TextTrackList): TextTrack | void {
if (!this.media) {
return;
}
for (let i = 0; i < textTracks.length; i++) {
const textTrack: TextTrack = textTracks[i];
if (textTrack.kind === 'metadata' && textTrack.label === 'id3') {
// send 'addtrack' when reusing the textTrack for metadata,
// same as what we do for captions
sendAddTrackEvent(textTrack, this.media);
return textTrack;
}
}
return this.media.addTextTrack('metadata', 'id3');
}
onFragParsingMetadata(
event: Events.FRAG_PARSING_METADATA,
data: FragParsingMetadataData
) {
if (!this.media) {
return;
}
const {
hls: {
config: { enableEmsgMetadataCues, enableID3MetadataCues },
},
} = this;
if (!enableEmsgMetadataCues && !enableID3MetadataCues) {
return;
}
const { frag: fragment, samples, details } = data;
// create track dynamically
if (!this.id3Track) {
this.id3Track = this.createTrack(this.media);
}
// VTTCue end time must be finite, so use playlist edge or fragment end until next fragment with same frame type is found
const maxCueTime = details.edge || fragment.end;
const Cue = getCueClass();
let updateCueRanges = false;
const frameTypesAdded: Record<string, number | null> = {};
for (let i = 0; i < samples.length; i++) {
const type = samples[i].type;
if (
(type === MetadataSchema.emsg && !enableEmsgMetadataCues) ||
!enableID3MetadataCues
) {
continue;
}
const frames = ID3.getID3Frames(samples[i].data);
if (frames) {
const startTime = samples[i].pts;
let endTime: number = maxCueTime;
const timeDiff = endTime - startTime;
if (timeDiff <= 0) {
endTime = startTime + MIN_CUE_DURATION;
}
for (let j = 0; j < frames.length; j++) {
const frame = frames[j];
// Safari doesn't put the timestamp frame in the TextTrack
if (!ID3.isTimeStampFrame(frame)) {
const cue = new Cue(startTime, endTime, '');
cue.value = frame;
if (type) {
cue.type = type;
}
this.id3Track.addCue(cue);
frameTypesAdded[frame.key] = null;
updateCueRanges = true;
}
}
}
}
if (updateCueRanges) {
this.updateId3CueEnds(frameTypesAdded);
}
}
updateId3CueEnds(frameTypesAdded: Record<string, number | null>) {
// Update endTime of previous cue with same IDR frame.type (Ex: TXXX cue spans to next TXXX)
const cues = this.id3Track?.cues;
if (cues) {
for (let i = cues.length; i--; ) {
const cue = cues[i] as any;
const frameType = cue.value?.key;
if (frameType && frameType in frameTypesAdded) {
const startTime = frameTypesAdded[frameType];
if (startTime && cue.endTime !== startTime) {
cue.endTime = startTime;
}
frameTypesAdded[frameType] = cue.startTime;
}
}
}
}
onBufferFlushing(
event: Events.BUFFER_FLUSHING,
{ startOffset, endOffset, type }: BufferFlushingData
) {
const { id3Track, hls } = this;
if (!hls) {
return;
}
const {
config: { enableEmsgMetadataCues, enableID3MetadataCues },
} = hls;
if (id3Track && (enableEmsgMetadataCues || enableID3MetadataCues)) {
let predicate;
if (type === 'audio') {
predicate = (cue) =>
(cue as any).type === MetadataSchema.audioId3 &&
enableID3MetadataCues;
} else if (type === 'video') {
predicate = (cue) =>
(cue as any).type === MetadataSchema.emsg && enableEmsgMetadataCues;
} else {
predicate = (cue) =>
((cue as any).type === MetadataSchema.audioId3 &&
enableID3MetadataCues) ||
((cue as any).type === MetadataSchema.emsg && enableEmsgMetadataCues);
}
removeCuesInRange(id3Track, startOffset, endOffset, predicate);
}
}
onLevelUpdated(event: Events.LEVEL_UPDATED, { details }: LevelUpdatedData) {
if (
!this.media ||
!details.hasProgramDateTime ||
!this.hls.config.enableDateRangeMetadataCues
) {
return;
}
const { dateRangeCuesAppended, id3Track } = this;
const { dateRanges } = details;
const ids = Object.keys(dateRanges);
// Remove cues from track not found in details.dateRanges
if (id3Track) {
const idsToRemove = Object.keys(dateRangeCuesAppended).filter(
(id) => !ids.includes(id)
);
for (let i = idsToRemove.length; i--; ) {
const id = idsToRemove[i];
Object.keys(dateRangeCuesAppended[id].cues).forEach((key) => {
id3Track.removeCue(dateRangeCuesAppended[id].cues[key]);
});
delete dateRangeCuesAppended[id];
}
}
// Exit if the playlist does not have Date Ranges or does not have Program Date Time
const lastFragment = details.fragments[details.fragments.length - 1];
if (ids.length === 0 || !Number.isFinite(lastFragment?.programDateTime)) {
return;
}
if (!this.id3Track) {
this.id3Track = this.createTrack(this.media);
}
const dateTimeOffset =
(lastFragment.programDateTime as number) / 1000 - lastFragment.start;
const maxCueTime = details.edge || lastFragment.end;
const Cue = getCueClass();
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const dateRange = dateRanges[id];
const appendedDateRangeCues = dateRangeCuesAppended[id];
const cues = appendedDateRangeCues?.cues || {};
let durationKnown = appendedDateRangeCues?.durationKnown || false;
const startTime = dateRangeDateToTimelineSeconds(
dateRange.startDate,
dateTimeOffset
);
let endTime = maxCueTime;
const endDate = dateRange.endDate;
if (endDate) {
endTime = dateRangeDateToTimelineSeconds(endDate, dateTimeOffset);
durationKnown = true;
} else if (dateRange.endOnNext && !durationKnown) {
const nextDateRangeWithSameClass = ids
.reduce((filterMapArray, id) => {
const candidate = dateRanges[id];
if (
candidate.class === dateRange.class &&
candidate.id !== id &&
candidate.startDate > dateRange.startDate
) {
filterMapArray.push(candidate);
}
return filterMapArray;
}, [] as DateRange[])
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())[0];
if (nextDateRangeWithSameClass) {
endTime = dateRangeDateToTimelineSeconds(
nextDateRangeWithSameClass.startDate,
dateTimeOffset
);
durationKnown = true;
}
}
const attributes = Object.keys(dateRange.attr);
for (let j = 0; j < attributes.length; j++) {
const key = attributes[j];
if (
key === DateRangeAttribute.ID ||
key === DateRangeAttribute.CLASS ||
key === DateRangeAttribute.START_DATE ||
key === DateRangeAttribute.DURATION ||
key === DateRangeAttribute.END_DATE ||
key === DateRangeAttribute.END_ON_NEXT
) {
continue;
}
let cue = cues[key] as any;
if (cue) {
if (durationKnown && !appendedDateRangeCues.durationKnown) {
cue.endTime = endTime;
}
} else {
let data = dateRange.attr[key];
cue = new Cue(startTime, endTime, '');
if (
key === DateRangeAttribute.SCTE35_OUT ||
key === DateRangeAttribute.SCTE35_IN
) {
data = hexToArrayBuffer(data);
}
cue.value = { key, data };
cue.type = MetadataSchema.dateRange;
this.id3Track.addCue(cue);
cues[key] = cue;
}
}
dateRangeCuesAppended[id] = {
cues,
dateRange,
durationKnown,
};
}
}
}
export default ID3TrackController;