Player: chapter markers
This commit is contained in:
parent
9fd890cfc2
commit
f2495df615
3 changed files with 187 additions and 0 deletions
176
ui/component/viewers/videoViewer/internal/chapters.jsx
Normal file
176
ui/component/viewers/videoViewer/internal/chapters.jsx
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
// @flow
|
||||||
|
const REQUIRED_DELAY_FOR_IOS_MS = 10;
|
||||||
|
const MIN_SECONDS_BETWEEN_CHAPTERS = 10;
|
||||||
|
const MIN_CHAPTERS = 3;
|
||||||
|
|
||||||
|
type TimestampData = Array<{ seconds: number, label: string }>;
|
||||||
|
|
||||||
|
function isValidTimestamp(str: string) {
|
||||||
|
let isValidTimestamp;
|
||||||
|
switch (str.length) {
|
||||||
|
case 4: // "9:59"
|
||||||
|
isValidTimestamp = /^[0-9]:[0-5][0-9]$/.test(str);
|
||||||
|
break;
|
||||||
|
case 5: // "59:59"
|
||||||
|
isValidTimestamp = /^[0-5][0-9]:[0-5][0-9]$/.test(str);
|
||||||
|
break;
|
||||||
|
case 7: // "9:59:59"
|
||||||
|
isValidTimestamp = /^[0-9]:[0-5][0-9]:[0-5][0-9]$/.test(str);
|
||||||
|
break;
|
||||||
|
case 8: // "99:59:59"
|
||||||
|
isValidTimestamp = /^[0-9][0-9]:[0-5][0-9]:[0-5][0-9]$/.test(str);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Reject
|
||||||
|
isValidTimestamp = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return isValidTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampStrToSeconds(ts: string) {
|
||||||
|
const parts = ts.split(':').reverse();
|
||||||
|
let seconds = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; ++i) {
|
||||||
|
const part = parts[i];
|
||||||
|
const parsed = parseInt(part);
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
seconds += parsed * Math.pow(60, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(claim: StreamClaim) {
|
||||||
|
// - Must have at least 3 timestamps.
|
||||||
|
// - First one must be 0:00.
|
||||||
|
// - Chapters must be at least 10 seconds apart.
|
||||||
|
// - Format: one per line, "0:00 Blah..."
|
||||||
|
|
||||||
|
const description = claim?.value?.description;
|
||||||
|
if (!description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = description.split('\n');
|
||||||
|
const timestamps = [];
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
if (line.length > 0) {
|
||||||
|
const splitIndex = line.indexOf(' ');
|
||||||
|
if (splitIndex >= 0 && splitIndex < line.length - 2) {
|
||||||
|
const ts = line.substring(0, splitIndex);
|
||||||
|
const label = line.substring(splitIndex) + 1;
|
||||||
|
|
||||||
|
if (ts && label && isValidTimestamp(ts)) {
|
||||||
|
const seconds = timestampStrToSeconds(ts);
|
||||||
|
|
||||||
|
if (timestamps.length !== 0) {
|
||||||
|
const prevSeconds = timestamps[timestamps.length - 1].seconds;
|
||||||
|
if (seconds - prevSeconds < MIN_SECONDS_BETWEEN_CHAPTERS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (seconds !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamps.push({ seconds, label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return timestamps.length >= MIN_CHAPTERS ? timestamps : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overrideHoverTooltip(player: any, tsData: TimestampData, duration: number) {
|
||||||
|
try {
|
||||||
|
const timeTooltip = player
|
||||||
|
.getChild('controlBar')
|
||||||
|
.getChild('progressControl')
|
||||||
|
.getChild('seekBar')
|
||||||
|
.getChild('mouseTimeDisplay')
|
||||||
|
.getChild('timeTooltip');
|
||||||
|
|
||||||
|
timeTooltip.update = function (seekBarRect, seekBarPoint, time) {
|
||||||
|
const values = Object.values(tsData);
|
||||||
|
// $FlowIssue: mixed
|
||||||
|
const seconds = values.map((v) => v.seconds);
|
||||||
|
const curSeconds = timestampStrToSeconds(time);
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
for (; i < seconds.length; ++i) {
|
||||||
|
const s0 = seconds[i];
|
||||||
|
const s1 = i === seconds.length - 1 ? duration : seconds[i + 1];
|
||||||
|
if (curSeconds >= s0 && curSeconds < s1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < seconds.length) {
|
||||||
|
// $FlowIssue: mixed
|
||||||
|
this.write(`${time} - ${values[i].label}`);
|
||||||
|
} else {
|
||||||
|
console.error('Chapters: oob ' + player?.claim?.name);
|
||||||
|
this.write(time);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(player: any, timestampData: TimestampData, duration: number) {
|
||||||
|
player.one('loadedmetadata', () => {
|
||||||
|
const textTrack = player.addTextTrack('chapters', 'label', 'en');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const values = Object.values(timestampData);
|
||||||
|
values.forEach((ts, index) => {
|
||||||
|
// $FlowIssue: mixed
|
||||||
|
const start = ts.seconds;
|
||||||
|
// $FlowIssue: mixed
|
||||||
|
const end = index === values.length - 1 ? duration : values[index + 1].seconds;
|
||||||
|
// $FlowIssue: mixed
|
||||||
|
textTrack.addCue(new window.VTTCue(start, end, ts.label));
|
||||||
|
});
|
||||||
|
|
||||||
|
addMarkersOnProgressBar(
|
||||||
|
// $FlowIssue: mixed
|
||||||
|
values.map((v) => v.seconds),
|
||||||
|
duration
|
||||||
|
);
|
||||||
|
}, REQUIRED_DELAY_FOR_IOS_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAndLoad(player: any, claim: StreamClaim) {
|
||||||
|
console.assert(claim, 'null claim');
|
||||||
|
const tsData = parse(claim);
|
||||||
|
const duration = claim?.value?.video?.duration || claim?.value?.audio?.duration;
|
||||||
|
|
||||||
|
if (tsData && duration) {
|
||||||
|
load(player, tsData, duration);
|
||||||
|
overrideHoverTooltip(player, tsData, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMarkersOnProgressBar(chapterStartTimes: Array<number>, videoDuration: number) {
|
||||||
|
const progressControl = document.getElementsByClassName('vjs-progress-holder vjs-slider vjs-slider-horizontal')[0];
|
||||||
|
if (!progressControl) {
|
||||||
|
console.error('Failed to find progress-control');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < chapterStartTimes.length; ++i) {
|
||||||
|
const elem = document.createElement('div');
|
||||||
|
// $FlowIssue
|
||||||
|
elem['className'] = 'vjs-chapter-marker';
|
||||||
|
// $FlowIssue
|
||||||
|
elem['id'] = 'chapter' + i;
|
||||||
|
elem.style.left = `${(chapterStartTimes[i] / videoDuration) * 100}%`;
|
||||||
|
progressControl.appendChild(elem);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import * as PAGES from 'constants/pages';
|
||||||
import * as ICONS from 'constants/icons';
|
import * as ICONS from 'constants/icons';
|
||||||
import React, { useEffect, useState, useContext, useCallback } from 'react';
|
import React, { useEffect, useState, useContext, useCallback } from 'react';
|
||||||
import { stopContextMenu } from 'util/context-menu';
|
import { stopContextMenu } from 'util/context-menu';
|
||||||
|
import * as Chapters from './internal/chapters';
|
||||||
import type { Player } from './internal/videojs';
|
import type { Player } from './internal/videojs';
|
||||||
import VideoJs from './internal/videojs';
|
import VideoJs from './internal/videojs';
|
||||||
import analytics from 'analytics';
|
import analytics from 'analytics';
|
||||||
|
@ -430,6 +431,8 @@ function VideoViewer(props: Props) {
|
||||||
player.currentTime(position);
|
player.currentTime(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Chapters.parseAndLoad(player, claim);
|
||||||
|
|
||||||
playerRef.current = player;
|
playerRef.current = player;
|
||||||
}, playerReadyDependencyList); // eslint-disable-line
|
}, playerReadyDependencyList); // eslint-disable-line
|
||||||
|
|
||||||
|
|
|
@ -113,6 +113,7 @@ $control-bar-icon-size: 0.8rem;
|
||||||
// Tooltip
|
// Tooltip
|
||||||
.vjs-mouse-display .vjs-time-tooltip {
|
.vjs-mouse-display .vjs-time-tooltip {
|
||||||
color: white;
|
color: white;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tooltip
|
// Tooltip
|
||||||
|
@ -339,3 +340,10 @@ button.vjs-big-play-button {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vjs-chapter-marker {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-error);
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue