timeline-chart.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950
  1. import Chart from 'chart.js';
  2. import { applyChartInstanceOverrides, hhmmss } from './chartjs-horizontal-bar';
  3. import { Fragment } from '../../src/loader/fragment';
  4. import type { Level } from '../../src/types/level';
  5. import type { TrackSet } from '../../src/types/track';
  6. import type { MediaPlaylist } from '../../src/types/media-playlist';
  7. import type { LevelDetails } from '../../src/loader/level-details';
  8. import {
  9. FragChangedData,
  10. FragLoadedData,
  11. FragParsedData,
  12. } from '../../src/types/events';
  13. declare global {
  14. interface Window {
  15. Hls: any;
  16. hls: any;
  17. chart: any;
  18. }
  19. }
  20. const X_AXIS_SECONDS = 'x-axis-seconds';
  21. interface ChartScale {
  22. width: number;
  23. height: number;
  24. min: number;
  25. max: number;
  26. options: any;
  27. determineDataLimits: () => void;
  28. buildTicks: () => void;
  29. getLabelForIndex: (index: number, datasetIndex: number) => string;
  30. getPixelForTick: (index: number) => number;
  31. getPixelForValue: (
  32. value: number,
  33. index?: number,
  34. datasetIndex?: number
  35. ) => number;
  36. getValueForPixel: (pixel: number) => number;
  37. }
  38. export class TimelineChart {
  39. private readonly chart: Chart;
  40. private rafDebounceRequestId: number = -1;
  41. private imageDataBuffer: ImageData | null = null;
  42. private media: HTMLMediaElement | null = null;
  43. private tracksChangeHandler?: (e) => void;
  44. private cuesChangeHandler?: (e) => void;
  45. private hidden: boolean = true;
  46. private zoom100: number = 60;
  47. constructor(canvas: HTMLCanvasElement, chartJsOptions?: any) {
  48. const ctx = canvas.getContext('2d');
  49. if (!ctx) {
  50. throw new Error(
  51. `Could not get CanvasRenderingContext2D from canvas: ${canvas}`
  52. );
  53. }
  54. const chart =
  55. (this.chart =
  56. self.chart =
  57. new Chart(ctx, {
  58. type: 'horizontalBar',
  59. data: {
  60. labels: [],
  61. datasets: [],
  62. },
  63. options: Object.assign(getChartOptions(), chartJsOptions),
  64. plugins: [
  65. {
  66. afterRender: (chart) => {
  67. this.imageDataBuffer = null;
  68. this.drawCurrentTime();
  69. },
  70. },
  71. ],
  72. }));
  73. applyChartInstanceOverrides(chart);
  74. canvas.ondblclick = (event: MouseEvent) => {
  75. const chart = this.chart;
  76. const chartArea: { left; top; right; bottom } = chart.chartArea;
  77. const element = chart.getElementAtEvent(event);
  78. const pos = Chart.helpers.getRelativePosition(event, chart);
  79. const scale = this.chartScales[X_AXIS_SECONDS];
  80. // zoom in when double clicking near elements in chart area
  81. if (element.length || pos.x > chartArea.left) {
  82. const amount = event.getModifierState('Shift') ? -1.0 : 0.5;
  83. this.zoom(scale, pos, amount);
  84. } else {
  85. scale.options.ticks.min = 0;
  86. scale.options.ticks.max = this.zoom100;
  87. }
  88. this.update();
  89. };
  90. canvas.onwheel = (event: WheelEvent) => {
  91. if (event.deltaMode) {
  92. // exit if wheel is in page or line scrolling mode
  93. return;
  94. }
  95. const chart = this.chart;
  96. const chartArea: { left; top; right; bottom } = chart.chartArea;
  97. const pos = Chart.helpers.getRelativePosition(event, chart);
  98. // zoom when scrolling over chart elements
  99. if (pos.x > chartArea.left - 11) {
  100. const scale = this.chartScales[X_AXIS_SECONDS];
  101. if (event.deltaY) {
  102. const direction = -event.deltaY / Math.abs(event.deltaY);
  103. const normal = Math.min(333, Math.abs(event.deltaY)) / 1000;
  104. const ease = 1 - (1 - normal) * (1 - normal);
  105. this.zoom(scale, pos, ease * direction);
  106. } else if (event.deltaX) {
  107. this.pan(scale, event.deltaX / 10, scale.min, scale.max);
  108. }
  109. event.preventDefault();
  110. }
  111. };
  112. let moved = false;
  113. let gestureScale = 1;
  114. canvas.onpointerdown = (downEvent: PointerEvent) => {
  115. if (!downEvent.isPrimary || gestureScale !== 1) {
  116. return;
  117. }
  118. const chart = this.chart;
  119. const chartArea: { left; top; right; bottom } = chart.chartArea;
  120. const pos = Chart.helpers.getRelativePosition(downEvent, chart);
  121. // pan when dragging over chart elements
  122. if (pos.x > chartArea.left) {
  123. const scale = this.chartScales[X_AXIS_SECONDS];
  124. const startX = downEvent.clientX;
  125. const { min, max } = scale;
  126. const xToVal = (max - min) / scale.width;
  127. moved = false;
  128. canvas.setPointerCapture(downEvent.pointerId);
  129. canvas.onpointermove = (moveEvent: PointerEvent) => {
  130. if (!downEvent.isPrimary || gestureScale !== 1) {
  131. return;
  132. }
  133. const movedX = startX - moveEvent.clientX;
  134. const movedValue = movedX * xToVal;
  135. moved = moved || Math.abs(movedX) > 8;
  136. this.pan(scale, movedValue, min, max);
  137. };
  138. }
  139. };
  140. canvas.onpointerup = canvas.onpointercancel = (upEvent: PointerEvent) => {
  141. if (canvas.onpointermove) {
  142. canvas.onpointermove = null;
  143. canvas.releasePointerCapture(upEvent.pointerId);
  144. }
  145. if (!moved && upEvent.isPrimary) {
  146. this.click(upEvent);
  147. }
  148. };
  149. // Gesture events are for iOS and easier to implement than pinch-zoom with multiple pointers for all browsers
  150. // @ts-ignore
  151. canvas.ongesturestart = (event) => {
  152. gestureScale = 1;
  153. event.preventDefault();
  154. };
  155. // @ts-ignore
  156. canvas.ongestureend = (event) => {
  157. gestureScale = 1;
  158. };
  159. // @ts-ignore
  160. canvas.ongesturechange = (event) => {
  161. const chart = this.chart;
  162. const chartArea: { left; top; right; bottom } = chart.chartArea;
  163. const pos = Chart.helpers.getRelativePosition(event, chart);
  164. // zoom when scrolling over chart elements
  165. if (pos.x > chartArea.left) {
  166. const scale = this.chartScales[X_AXIS_SECONDS];
  167. const amount = event.scale - gestureScale;
  168. this.zoom(scale, pos, amount);
  169. gestureScale = event.scale;
  170. }
  171. };
  172. }
  173. private click(event: MouseEvent) {
  174. // Log object on click and seek to position
  175. const chart = this.chart;
  176. const element = chart.getElementAtEvent(event);
  177. if (element.length && chart.data.datasets) {
  178. const dataset = chart.data.datasets[(element[0] as any)._datasetIndex];
  179. const obj = dataset.data![(element[0] as any)._index];
  180. // eslint-disable-next-line no-console
  181. console.log(obj);
  182. if (self.hls?.media) {
  183. const scale = this.chartScales[X_AXIS_SECONDS];
  184. const pos = Chart.helpers.getRelativePosition(event, chart);
  185. self.hls.media.currentTime = scale.getValueForPixel(pos.x);
  186. }
  187. }
  188. }
  189. private pan(scale: ChartScale, amount: number, min: number, max: number) {
  190. if (amount === 0) {
  191. return;
  192. }
  193. let pan = amount;
  194. if (amount > 0) {
  195. pan = Math.min(this.zoom100 + 10 - max, amount);
  196. } else {
  197. pan = Math.max(-10 - min, amount);
  198. }
  199. scale.options.ticks.min = min + pan;
  200. scale.options.ticks.max = max + pan;
  201. this.updateOnRepaint();
  202. }
  203. private zoom(scale: ChartScale, pos: any, amount: number) {
  204. const range = scale.max - scale.min;
  205. const diff = range * amount;
  206. const minPercent = (scale.getValueForPixel(pos.x) - scale.min) / range;
  207. const maxPercent = 1 - minPercent;
  208. const minDelta = diff * minPercent;
  209. const maxDelta = diff * maxPercent;
  210. scale.options.ticks.min = Math.max(-10, scale.min + minDelta);
  211. scale.options.ticks.max = Math.min(this.zoom100 + 10, scale.max - maxDelta);
  212. this.updateOnRepaint();
  213. }
  214. get chartScales(): { 'x-axis-seconds': ChartScale } {
  215. return (this.chart as any).scales;
  216. }
  217. reset() {
  218. const scale = this.chartScales[X_AXIS_SECONDS];
  219. scale.options.ticks.min = 0;
  220. scale.options.ticks.max = 60;
  221. const { labels, datasets } = this.chart.data;
  222. if (labels && datasets) {
  223. labels.length = 0;
  224. datasets.length = 0;
  225. this.resize(datasets);
  226. }
  227. }
  228. update() {
  229. if (this.hidden || !this.chart.ctx?.canvas.width) {
  230. return;
  231. }
  232. self.cancelAnimationFrame(this.rafDebounceRequestId);
  233. this.chart.update({
  234. duration: 0,
  235. lazy: true,
  236. });
  237. }
  238. updateOnRepaint() {
  239. if (this.hidden) {
  240. return;
  241. }
  242. self.cancelAnimationFrame(this.rafDebounceRequestId);
  243. this.rafDebounceRequestId = self.requestAnimationFrame(() => this.update());
  244. }
  245. resize(datasets?) {
  246. if (this.hidden) {
  247. return;
  248. }
  249. if (datasets?.length) {
  250. const scale = this.chartScales[X_AXIS_SECONDS];
  251. const { top } = this.chart.chartArea;
  252. const height =
  253. top +
  254. datasets.reduce((val, dataset) => val + dataset.barThickness, 0) +
  255. scale.height +
  256. 5;
  257. const container = this.chart.canvas?.parentElement;
  258. if (container) {
  259. container.style.height = `${height}px`;
  260. }
  261. }
  262. self.cancelAnimationFrame(this.rafDebounceRequestId);
  263. this.rafDebounceRequestId = self.requestAnimationFrame(() => {
  264. this.chart.resize();
  265. });
  266. }
  267. show() {
  268. this.hidden = false;
  269. }
  270. hide() {
  271. this.hidden = true;
  272. }
  273. updateLevels(levels: Level[], levelSwitched) {
  274. const { labels, datasets } = this.chart.data;
  275. if (!labels || !datasets) {
  276. return;
  277. }
  278. const { loadLevel, nextLoadLevel, nextAutoLevel } = self.hls;
  279. // eslint-disable-next-line no-undefined
  280. const currentLevel =
  281. levelSwitched !== undefined ? levelSwitched : self.hls.currentLevel;
  282. levels.forEach((level, i) => {
  283. const index = level.id || i;
  284. labels.push(getLevelName(level, index));
  285. let borderColor: string | null = null;
  286. if (currentLevel === i) {
  287. borderColor = 'rgba(32, 32, 240, 1.0)';
  288. } else if (loadLevel === i) {
  289. borderColor = 'rgba(255, 128, 0, 1.0)';
  290. } else if (nextLoadLevel === i) {
  291. borderColor = 'rgba(200, 200, 64, 1.0)';
  292. } else if (nextAutoLevel === i) {
  293. borderColor = 'rgba(160, 0, 160, 1.0)';
  294. }
  295. datasets.push(
  296. datasetWithDefaults({
  297. url: Array.isArray(level.url)
  298. ? level.url[level.urlId || 0]
  299. : level.url,
  300. trackType: 'level',
  301. borderColor,
  302. level: index,
  303. })
  304. );
  305. if (level.details) {
  306. this.updateLevelOrTrack(level.details);
  307. }
  308. });
  309. this.resize(datasets);
  310. }
  311. updateAudioTracks(audioTracks: MediaPlaylist[]) {
  312. const { labels, datasets } = this.chart.data;
  313. if (!labels || !datasets) {
  314. return;
  315. }
  316. const { audioTrack } = self.hls;
  317. audioTracks.forEach((track: MediaPlaylist, i) => {
  318. labels.push(getAudioTrackName(track, i));
  319. datasets.push(
  320. datasetWithDefaults({
  321. url: track.url,
  322. trackType: 'audioTrack',
  323. borderColor: audioTrack === i ? 'rgba(32, 32, 240, 1.0)' : null,
  324. audioTrack: i,
  325. })
  326. );
  327. if (track.details) {
  328. this.updateLevelOrTrack(track.details);
  329. }
  330. });
  331. this.resize(datasets);
  332. }
  333. updateSubtitleTracks(subtitles: MediaPlaylist[]) {
  334. const { labels, datasets } = this.chart.data;
  335. if (!labels || !datasets) {
  336. return;
  337. }
  338. const { subtitleTrack } = self.hls;
  339. subtitles.forEach((track, i) => {
  340. labels.push(getSubtitlesName(track, i));
  341. datasets.push(
  342. datasetWithDefaults({
  343. url: track.url,
  344. trackType: 'subtitleTrack',
  345. borderColor: subtitleTrack === i ? 'rgba(32, 32, 240, 1.0)' : null,
  346. subtitleTrack: i,
  347. })
  348. );
  349. if (track.details) {
  350. this.updateLevelOrTrack(track.details);
  351. }
  352. });
  353. this.resize(datasets);
  354. }
  355. removeType(
  356. trackType: 'level' | 'audioTrack' | 'subtitleTrack' | 'textTrack'
  357. ) {
  358. const { labels, datasets } = this.chart.data;
  359. if (!labels || !datasets) {
  360. return;
  361. }
  362. let i = datasets.length;
  363. while (i--) {
  364. if ((datasets[i] as any).trackType === trackType) {
  365. datasets.splice(i, 1);
  366. labels.splice(i, 1);
  367. }
  368. }
  369. }
  370. updateLevelOrTrack(details: LevelDetails) {
  371. const { targetduration, totalduration, url } = details;
  372. const { datasets } = this.chart.data;
  373. let levelDataSet = arrayFind(
  374. datasets,
  375. (dataset) =>
  376. stripDeliveryDirectives(url) ===
  377. stripDeliveryDirectives(dataset.url || '')
  378. );
  379. if (!levelDataSet) {
  380. levelDataSet = arrayFind(
  381. datasets,
  382. (dataset) => details.fragments[0]?.level === dataset.level
  383. );
  384. }
  385. if (!levelDataSet) {
  386. return;
  387. }
  388. const data = levelDataSet.data;
  389. data.length = 0;
  390. if (details.fragments) {
  391. details.fragments.forEach((fragment) => {
  392. // TODO: keep track of initial playlist start and duration so that we can show drift and pts offset
  393. // (Make that a feature of hls.js v1.0.0 fragments)
  394. const chartFragment = Object.assign(
  395. {
  396. dataType: 'fragment',
  397. },
  398. fragment,
  399. // Remove loader references for GC
  400. { loader: null }
  401. );
  402. data.push(chartFragment);
  403. });
  404. }
  405. if (details.partList) {
  406. details.partList.forEach((part) => {
  407. const chartPart = Object.assign(
  408. {
  409. dataType: 'part',
  410. start: part.fragment.start + part.fragOffset,
  411. },
  412. part,
  413. {
  414. fragment: Object.assign({}, part.fragment, { loader: null }),
  415. }
  416. );
  417. data.push(chartPart);
  418. });
  419. if (details.fragmentHint) {
  420. const chartFragment = Object.assign(
  421. {
  422. dataType: 'fragmentHint',
  423. },
  424. details.fragmentHint,
  425. // Remove loader references for GC
  426. { loader: null }
  427. );
  428. data.push(chartFragment);
  429. }
  430. }
  431. const start = getPlaylistStart(details);
  432. this.maxZoom = this.zoom100 = Math.max(
  433. start + totalduration + targetduration * 3,
  434. this.zoom100
  435. );
  436. this.updateOnRepaint();
  437. }
  438. // @ts-ignore
  439. get minZoom(): number {
  440. const scale = this.chartScales[X_AXIS_SECONDS];
  441. if (scale) {
  442. return scale.options.ticks.min;
  443. }
  444. return 1;
  445. }
  446. // @ts-ignore
  447. get maxZoom(): number {
  448. const scale = this.chartScales[X_AXIS_SECONDS];
  449. if (scale) {
  450. return scale.options.ticks.max;
  451. }
  452. return this.zoom100;
  453. }
  454. // @ts-ignore
  455. set maxZoom(x: number) {
  456. const currentZoom = this.maxZoom;
  457. const newZoom = Math.max(x, currentZoom);
  458. if (currentZoom === 60 && newZoom !== currentZoom) {
  459. const scale = this.chartScales[X_AXIS_SECONDS];
  460. scale.options.ticks.max = newZoom;
  461. }
  462. }
  463. updateFragment(data: FragLoadedData | FragParsedData | FragChangedData) {
  464. const { datasets } = this.chart.data;
  465. const frag: Fragment = data.frag;
  466. let levelDataSet = arrayFind(
  467. datasets,
  468. (dataset) => frag.baseurl === dataset.url
  469. );
  470. if (!levelDataSet) {
  471. levelDataSet = arrayFind(
  472. datasets,
  473. (dataset) => frag.level === dataset.level
  474. );
  475. }
  476. if (!levelDataSet) {
  477. return;
  478. }
  479. // eslint-disable-next-line no-restricted-properties
  480. const fragData = arrayFind(
  481. levelDataSet.data,
  482. (fragData) => fragData.relurl === frag.relurl && fragData.sn === frag.sn
  483. );
  484. if (fragData && fragData !== frag) {
  485. Object.assign(fragData, frag);
  486. }
  487. this.updateOnRepaint();
  488. }
  489. updateSourceBuffers(tracks: TrackSet, media: HTMLMediaElement) {
  490. const { labels, datasets } = this.chart.data;
  491. if (!labels || !datasets) {
  492. return;
  493. }
  494. const trackTypes = Object.keys(tracks).sort((type) =>
  495. type === 'video' ? 1 : -1
  496. );
  497. const mediaBufferData = [];
  498. this.removeSourceBuffers();
  499. this.media = media;
  500. trackTypes.forEach((type) => {
  501. const track = tracks[type];
  502. const data = [];
  503. const sourceBuffer = track.buffer;
  504. const backgroundColor = {
  505. video: 'rgba(0, 0, 255, 0.2)',
  506. audio: 'rgba(128, 128, 0, 0.2)',
  507. audiovideo: 'rgba(128, 128, 255, 0.2)',
  508. }[type];
  509. labels.unshift(`${type} buffer (${track.id})`);
  510. datasets.unshift(
  511. datasetWithDefaults({
  512. data,
  513. categoryPercentage: 0.5,
  514. backgroundColor,
  515. sourceBuffer,
  516. })
  517. );
  518. sourceBuffer.addEventListener('update', () => {
  519. try {
  520. replaceTimeRangeTuples(sourceBuffer.buffered, data);
  521. } catch (error) {
  522. // eslint-disable-next-line no-console
  523. console.warn(error);
  524. return;
  525. }
  526. replaceTimeRangeTuples(media.buffered, mediaBufferData);
  527. this.update();
  528. });
  529. });
  530. if (trackTypes.length === 0) {
  531. media.onprogress = () => {
  532. replaceTimeRangeTuples(media.buffered, mediaBufferData);
  533. this.update();
  534. };
  535. }
  536. labels.unshift('media buffer');
  537. datasets.unshift(
  538. datasetWithDefaults({
  539. data: mediaBufferData,
  540. categoryPercentage: 0.5,
  541. backgroundColor: 'rgba(0, 255, 0, 0.2)',
  542. media,
  543. })
  544. );
  545. media.ontimeupdate = () => this.drawCurrentTime();
  546. // TextTrackList
  547. const { textTracks } = media;
  548. this.tracksChangeHandler =
  549. this.tracksChangeHandler || ((e) => this.setTextTracks(e.currentTarget));
  550. textTracks.removeEventListener('addtrack', this.tracksChangeHandler);
  551. textTracks.removeEventListener('removetrack', this.tracksChangeHandler);
  552. textTracks.removeEventListener('change', this.tracksChangeHandler);
  553. textTracks.addEventListener('addtrack', this.tracksChangeHandler);
  554. textTracks.addEventListener('removetrack', this.tracksChangeHandler);
  555. textTracks.addEventListener('change', this.tracksChangeHandler);
  556. this.setTextTracks(textTracks);
  557. this.resize(datasets);
  558. }
  559. removeSourceBuffers() {
  560. const { labels, datasets } = this.chart.data;
  561. if (!labels || !datasets) {
  562. return;
  563. }
  564. let i = datasets.length;
  565. while (i--) {
  566. if ((labels[0] || '').toString().indexOf('buffer') > -1) {
  567. datasets.splice(i, 1);
  568. labels.splice(i, 1);
  569. }
  570. }
  571. }
  572. setTextTracks(textTracks) {
  573. const { labels, datasets } = this.chart.data;
  574. if (!labels || !datasets) {
  575. return;
  576. }
  577. this.removeType('textTrack');
  578. [].forEach.call(textTracks, (textTrack, i) => {
  579. // Uncomment to disable rending of subtitle/caption cues in the timeline
  580. // if (textTrack.kind === 'subtitles' || textTrack.kind === 'captions') {
  581. // return;
  582. // }
  583. const data = [];
  584. labels.push(
  585. `${textTrack.name || textTrack.label} ${textTrack.kind} (${
  586. textTrack.mode
  587. })`
  588. );
  589. datasets.push(
  590. datasetWithDefaults({
  591. data,
  592. categoryPercentage: 0.5,
  593. url: '',
  594. trackType: 'textTrack',
  595. borderColor:
  596. (textTrack.mode !== 'hidden') === i
  597. ? 'rgba(32, 32, 240, 1.0)'
  598. : null,
  599. textTrack: i,
  600. })
  601. );
  602. this.cuesChangeHandler =
  603. this.cuesChangeHandler ||
  604. ((e) => this.updateTextTrackCues(e.currentTarget));
  605. textTrack._data = data;
  606. textTrack.removeEventListener('cuechange', this.cuesChangeHandler);
  607. textTrack.addEventListener('cuechange', this.cuesChangeHandler);
  608. this.updateTextTrackCues(textTrack);
  609. });
  610. this.resize(datasets);
  611. }
  612. updateTextTrackCues(textTrack) {
  613. const data = textTrack._data;
  614. if (!data) {
  615. return;
  616. }
  617. const { activeCues, cues } = textTrack;
  618. data.length = 0;
  619. if (!cues) {
  620. return;
  621. }
  622. const length = cues.length;
  623. let activeLength = 0;
  624. let activeMin = Infinity;
  625. let activeMax = 0;
  626. if (activeCues) {
  627. activeLength = activeCues.length;
  628. for (let i = 0; i < activeLength; i++) {
  629. let cue = activeCues[i];
  630. if (!cue && activeCues.item) {
  631. cue = activeCues.item(i);
  632. }
  633. if (cue) {
  634. activeMin = Math.min(activeMin, cue.startTime);
  635. activeMax = cue.endTime
  636. ? Math.max(activeMax, cue.endTime)
  637. : activeMax;
  638. } else {
  639. activeLength--;
  640. }
  641. }
  642. }
  643. for (let i = 0; i < length; i++) {
  644. let cue = cues[i];
  645. if (!cue && cues.item) {
  646. cue = cues.item(i);
  647. }
  648. if (!cue) {
  649. continue;
  650. }
  651. const start = cue.startTime;
  652. const end = cue.endTime;
  653. const content = getCueLabel(cue);
  654. let active = false;
  655. if (activeLength && end >= activeMin && start <= activeMax) {
  656. active = [].some.call(activeCues, (activeCue) =>
  657. cuesMatch(activeCue, cue)
  658. );
  659. }
  660. data.push({
  661. start,
  662. end,
  663. content,
  664. active,
  665. dataType: 'cue',
  666. });
  667. }
  668. this.updateOnRepaint();
  669. }
  670. drawCurrentTime() {
  671. const chart = this.chart;
  672. if (self.hls?.media && chart.data.datasets!.length) {
  673. const currentTime = self.hls.media.currentTime;
  674. const scale = this.chartScales[X_AXIS_SECONDS];
  675. const ctx = chart.ctx;
  676. if (this.hidden || !ctx || !ctx.canvas.width) {
  677. return;
  678. }
  679. const chartArea: { left; top; right; bottom } = chart.chartArea;
  680. const x = scale.getPixelForValue(currentTime);
  681. ctx.restore();
  682. ctx.save();
  683. this.drawLineX(ctx, x, chartArea);
  684. if (x > chartArea.left && x < chartArea.right) {
  685. ctx.fillStyle = this.getCurrentTimeColor(self.hls.media);
  686. const y = chartArea.top + chart.data.datasets![0].barThickness + 1;
  687. ctx.fillText(hhmmss(currentTime, 5), x + 2, y, 100);
  688. }
  689. ctx.restore();
  690. }
  691. }
  692. getCurrentTimeColor(video: HTMLMediaElement): string {
  693. if (!video.readyState || video.ended) {
  694. return 'rgba(0, 0, 0, 0.9)';
  695. }
  696. if (video.seeking || video.readyState < 3) {
  697. return 'rgba(255, 128, 0, 0.9)';
  698. }
  699. if (video.paused) {
  700. return 'rgba(128, 0, 255, 0.9)';
  701. }
  702. return 'rgba(0, 0, 255, 0.9)';
  703. }
  704. drawLineX(ctx, x: number, chartArea) {
  705. if (!this.imageDataBuffer) {
  706. const devicePixelRatio = self.devicePixelRatio || 1;
  707. this.imageDataBuffer = ctx.getImageData(
  708. 0,
  709. 0,
  710. chartArea.right * devicePixelRatio,
  711. chartArea.bottom * devicePixelRatio
  712. );
  713. } else {
  714. ctx.fillStyle = '#ffffff';
  715. ctx.fillRect(0, 0, chartArea.right, chartArea.bottom);
  716. ctx.putImageData(this.imageDataBuffer, 0, 0);
  717. }
  718. if (x > chartArea.left && x < chartArea.right) {
  719. ctx.lineWidth = 1;
  720. ctx.strokeStyle = this.getCurrentTimeColor(self.hls.media); // alpha '0.5'
  721. ctx.beginPath();
  722. ctx.moveTo(x, chartArea.top);
  723. ctx.lineTo(x, chartArea.bottom);
  724. ctx.stroke();
  725. }
  726. }
  727. }
  728. function stripDeliveryDirectives(url: string): string {
  729. if (url === '') {
  730. return url;
  731. }
  732. try {
  733. const webUrl: URL = new self.URL(url);
  734. webUrl.searchParams.delete('_HLS_msn');
  735. webUrl.searchParams.delete('_HLS_part');
  736. webUrl.searchParams.delete('_HLS_skip');
  737. webUrl.searchParams.sort();
  738. return webUrl.href;
  739. } catch (e) {
  740. return url.replace(/[?&]_HLS_(?:msn|part|skip)=[^?&]+/g, '');
  741. }
  742. }
  743. function datasetWithDefaults(options) {
  744. return Object.assign(
  745. {
  746. data: [],
  747. xAxisID: X_AXIS_SECONDS,
  748. barThickness: 35,
  749. categoryPercentage: 1,
  750. },
  751. options
  752. );
  753. }
  754. function getPlaylistStart(details: LevelDetails): number {
  755. return details.fragments?.length ? details.fragments[0].start : 0;
  756. }
  757. function getLevelName(level: Level, index: number) {
  758. let label = '(main playlist)';
  759. if (level.attrs?.BANDWIDTH) {
  760. label = `${getMainLevelAttribute(level)}@${level.attrs.BANDWIDTH}`;
  761. if (level.name) {
  762. label = `${label} (${level.name})`;
  763. }
  764. } else if (level.name) {
  765. label = level.name;
  766. }
  767. return `${label} L-${index}`;
  768. }
  769. function getMainLevelAttribute(level: Level) {
  770. return level.attrs.RESOLUTION || level.attrs.CODECS || level.attrs.AUDIO;
  771. }
  772. function getAudioTrackName(track: MediaPlaylist, index: number) {
  773. const label = track.lang ? `${track.name}/${track.lang}` : track.name;
  774. return `${label} (${track.groupId || track.attrs['GROUP-ID']}) A-${index}`;
  775. }
  776. function getSubtitlesName(track: MediaPlaylist, index: number) {
  777. const label = track.lang ? `${track.name}/${track.lang}` : track.name;
  778. return `${label} (${track.groupId || track.attrs['GROUP-ID']}) S-${index}`;
  779. }
  780. function replaceTimeRangeTuples(timeRanges, data) {
  781. data.length = 0;
  782. const { length } = timeRanges;
  783. for (let i = 0; i < length; i++) {
  784. data.push([timeRanges.start(i), timeRanges.end(i)]);
  785. }
  786. }
  787. function cuesMatch(cue1, cue2) {
  788. return (
  789. cue1.startTime === cue2.startTime &&
  790. cue1.endTime === cue2.endTime &&
  791. cue1.text === cue2.text &&
  792. cue1.data === cue2.data &&
  793. JSON.stringify(cue1.value) === JSON.stringify(cue2.value)
  794. );
  795. }
  796. function getCueLabel(cue) {
  797. if (cue.text) {
  798. return cue.text;
  799. }
  800. const result = parseDataCue(cue);
  801. return JSON.stringify(result);
  802. }
  803. function parseDataCue(cue) {
  804. const data = {};
  805. const { value } = cue;
  806. if (value) {
  807. if (value.info) {
  808. let collection = data[value.key];
  809. if (collection !== Object(collection)) {
  810. collection = {};
  811. data[value.key] = collection;
  812. }
  813. collection[value.info] = value.data;
  814. } else {
  815. data[value.key] = value.data;
  816. }
  817. }
  818. return data;
  819. }
  820. function getChartOptions() {
  821. return {
  822. animation: {
  823. duration: 0,
  824. },
  825. elements: {
  826. rectangle: {
  827. borderWidth: 1,
  828. borderColor: 'rgba(20, 20, 20, 1)',
  829. },
  830. },
  831. events: ['click', 'touchstart'],
  832. hover: {
  833. mode: null,
  834. animationDuration: 0,
  835. },
  836. legend: {
  837. display: false,
  838. },
  839. maintainAspectRatio: false,
  840. responsiveAnimationDuration: 0,
  841. scales: {
  842. // TODO: additional xAxes for PTS and PDT
  843. xAxes: [
  844. {
  845. id: X_AXIS_SECONDS,
  846. ticks: {
  847. beginAtZero: true,
  848. sampleSize: 0,
  849. maxRotation: 0,
  850. callback: (tickValue, i, ticks) => {
  851. if (i === 0 || i === ticks.length - 1) {
  852. return tickValue ? '' : '0';
  853. } else {
  854. return hhmmss(tickValue, 2);
  855. }
  856. },
  857. },
  858. },
  859. ],
  860. yAxes: [
  861. {
  862. gridLines: {
  863. display: false,
  864. },
  865. },
  866. ],
  867. },
  868. tooltips: {
  869. enabled: false,
  870. },
  871. };
  872. }
  873. function arrayFind(array, predicate) {
  874. const len = array.length >>> 0;
  875. if (typeof predicate !== 'function') {
  876. throw TypeError('predicate must be a function');
  877. }
  878. const thisArg = arguments[2];
  879. let k = 0;
  880. while (k < len) {
  881. const kValue = array[k];
  882. if (predicate.call(thisArg, kValue, k, array)) {
  883. return kValue;
  884. }
  885. k++;
  886. }
  887. // eslint-disable-next-line no-undefined
  888. return undefined;
  889. }