4b6e4db7cd
- Deemed not useful in mobile, unless you have an S-pen that can hover. - The chapters button (that invokes the chapters popup) would make more sense in Mobile, but we need to deal with the limited controlbar space first (e.g. overflow menu system)
183 lines
5 KiB
JavaScript
183 lines
5 KiB
JavaScript
// @flow
|
|
import { platform } from 'util/platform';
|
|
|
|
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');
|
|
|
|
if (platform.isMobile()) {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|