From f2495df6157513f81c7481d6ce621d792b01fc22 Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Mon, 2 May 2022 16:55:08 +0800 Subject: [PATCH] Player: chapter markers --- .../viewers/videoViewer/internal/chapters.jsx | 176 ++++++++++++++++++ ui/component/viewers/videoViewer/view.jsx | 3 + ui/scss/component/_videojs.scss | 8 + 3 files changed, 187 insertions(+) create mode 100644 ui/component/viewers/videoViewer/internal/chapters.jsx diff --git a/ui/component/viewers/videoViewer/internal/chapters.jsx b/ui/component/viewers/videoViewer/internal/chapters.jsx new file mode 100644 index 000000000..8e57ac135 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/chapters.jsx @@ -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, 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); + } +} diff --git a/ui/component/viewers/videoViewer/view.jsx b/ui/component/viewers/videoViewer/view.jsx index 181fc6f8c..02020f217 100644 --- a/ui/component/viewers/videoViewer/view.jsx +++ b/ui/component/viewers/videoViewer/view.jsx @@ -4,6 +4,7 @@ import * as PAGES from 'constants/pages'; import * as ICONS from 'constants/icons'; import React, { useEffect, useState, useContext, useCallback } from 'react'; import { stopContextMenu } from 'util/context-menu'; +import * as Chapters from './internal/chapters'; import type { Player } from './internal/videojs'; import VideoJs from './internal/videojs'; import analytics from 'analytics'; @@ -430,6 +431,8 @@ function VideoViewer(props: Props) { player.currentTime(position); } + Chapters.parseAndLoad(player, claim); + playerRef.current = player; }, playerReadyDependencyList); // eslint-disable-line diff --git a/ui/scss/component/_videojs.scss b/ui/scss/component/_videojs.scss index 81a32bd9b..04dac9f06 100644 --- a/ui/scss/component/_videojs.scss +++ b/ui/scss/component/_videojs.scss @@ -113,6 +113,7 @@ $control-bar-icon-size: 0.8rem; // Tooltip .vjs-mouse-display .vjs-time-tooltip { color: white; + white-space: nowrap; } // Tooltip @@ -339,3 +340,10 @@ button.vjs-big-play-button { display: none !important; } } + +.vjs-chapter-marker { + position: absolute; + height: 100%; + background-color: var(--color-error); + width: 2px; +}