mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Scene player improvements (#3020)
* Add types to player plugins * Use videojs-vtt.js to parse sprite VTT files * Overhaul scene player * Replace vtt-thumbnails-freetube * Remove chapters_vtt * Force remove shadow from player progress bar * Cleanup player css * Rewrite live.ts as middleware * Don't force play when changing source
This commit is contained in:
@@ -20,7 +20,6 @@ fragment SlimSceneData on Scene {
|
||||
stream
|
||||
webp
|
||||
vtt
|
||||
chapters_vtt
|
||||
sprite
|
||||
funscript
|
||||
interactive_heatmap
|
||||
|
||||
@@ -26,7 +26,6 @@ fragment SceneData on Scene {
|
||||
stream
|
||||
webp
|
||||
vtt
|
||||
chapters_vtt
|
||||
sprite
|
||||
funscript
|
||||
interactive_heatmap
|
||||
|
||||
@@ -15,7 +15,6 @@ type ScenePathsType {
|
||||
stream: String # Resolver
|
||||
webp: String # Resolver
|
||||
vtt: String # Resolver
|
||||
chapters_vtt: String # Resolver
|
||||
sprite: String # Resolver
|
||||
funscript: String # Resolver
|
||||
interactive_heatmap: String # Resolver
|
||||
|
||||
@@ -172,7 +172,6 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat
|
||||
webpPath := builder.GetStreamPreviewImageURL()
|
||||
vttPath := builder.GetSpriteVTTURL()
|
||||
spritePath := builder.GetSpriteURL()
|
||||
chaptersVttPath := builder.GetChaptersVTTURL()
|
||||
funscriptPath := builder.GetFunscriptURL()
|
||||
captionBasePath := builder.GetCaptionURL()
|
||||
interactiveHeatmap := builder.GetInteractiveHeatmapURL()
|
||||
@@ -183,7 +182,6 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat
|
||||
Stream: &streamPath,
|
||||
Webp: &webpPath,
|
||||
Vtt: &vttPath,
|
||||
ChaptersVtt: &chaptersVttPath,
|
||||
Sprite: &spritePath,
|
||||
Funscript: &funscriptPath,
|
||||
InteractiveHeatmap: &interactiveHeatmap,
|
||||
|
||||
@@ -65,7 +65,6 @@ func (rs sceneRoutes) Routes() chi.Router {
|
||||
r.Get("/screenshot", rs.Screenshot)
|
||||
r.Get("/preview", rs.Preview)
|
||||
r.Get("/webp", rs.Webp)
|
||||
r.Get("/vtt/chapter", rs.ChapterVtt)
|
||||
r.Get("/funscript", rs.Funscript)
|
||||
r.Get("/interactive_heatmap", rs.InteractiveHeatmap)
|
||||
r.Get("/caption", rs.CaptionLang)
|
||||
@@ -258,80 +257,6 @@ func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, filepath)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) getChapterVttTitle(ctx context.Context, marker *models.SceneMarker) (*string, error) {
|
||||
if marker.Title != "" {
|
||||
return &marker.Title, nil
|
||||
}
|
||||
|
||||
var title string
|
||||
if err := txn.WithTxn(ctx, rs.txnManager, func(ctx context.Context) error {
|
||||
qb := rs.tagFinder
|
||||
primaryTag, err := qb.Find(ctx, marker.PrimaryTagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title = primaryTag.Name
|
||||
|
||||
tags, err := qb.FindBySceneMarkerID(ctx, marker.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, t := range tags {
|
||||
title += ", " + t.Name
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &title, nil
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
var sceneMarkers []*models.SceneMarker
|
||||
readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
var err error
|
||||
sceneMarkers, err = rs.sceneMarkerFinder.FindBySceneID(ctx, scene.ID)
|
||||
return err
|
||||
})
|
||||
if errors.Is(readTxnErr, context.Canceled) {
|
||||
return
|
||||
}
|
||||
if readTxnErr != nil {
|
||||
logger.Warnf("read transaction error on fetch scene markers: %v", readTxnErr)
|
||||
http.Error(w, readTxnErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vttLines := []string{"WEBVTT", ""}
|
||||
for i, marker := range sceneMarkers {
|
||||
vttLines = append(vttLines, strconv.Itoa(i+1))
|
||||
time := utils.GetVTTTime(marker.Seconds)
|
||||
vttLines = append(vttLines, time+" --> "+time)
|
||||
|
||||
vttTitle, err := rs.getChapterVttTitle(r.Context(), marker)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warnf("read transaction error on fetch scene marker title: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vttLines = append(vttLines, *vttTitle)
|
||||
vttLines = append(vttLines, "")
|
||||
}
|
||||
vtt := strings.Join(vttLines, "\n")
|
||||
|
||||
w.Header().Set("Content-Type", "text/vtt")
|
||||
_, _ = w.Write([]byte(vtt))
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
|
||||
s := r.Context().Value(sceneKey).(*models.Scene)
|
||||
funscript := video.GetFunscriptPath(s.Path)
|
||||
|
||||
@@ -55,10 +55,6 @@ func (b SceneURLBuilder) GetScreenshotURL(updateTime time.Time) string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?" + strconv.FormatInt(updateTime.Unix(), 10)
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetChaptersVTTURL() string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/vtt/chapter"
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetSceneMarkerStreamURL(sceneMarkerID int) string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + strconv.Itoa(sceneMarkerID) + "/stream"
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/react-select": "^4.0.8",
|
||||
"ansi-regex": "^5.0.1",
|
||||
"apollo-upload-client": "^14.1.3",
|
||||
"axios": "0.24.0",
|
||||
"axios": "^1.1.3",
|
||||
"base64-blob": "^1.4.1",
|
||||
"bootstrap": "^4.6.0",
|
||||
"classnames": "^2.2.6",
|
||||
@@ -47,7 +47,7 @@
|
||||
"graphql-tag": "^2.11.0",
|
||||
"i18n-iso-countries": "^6.4.0",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"localforage": "1.9.0",
|
||||
"localforage": "^1.9.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mousetrap-pause": "^1.0.0",
|
||||
@@ -72,10 +72,10 @@
|
||||
"subscriptions-transport-ws": "^0.9.18",
|
||||
"thehandy": "^1.0.3",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"video.js": "^7.17.0",
|
||||
"video.js": "^7.20.3",
|
||||
"videojs-landscape-fullscreen": "^11.33.0",
|
||||
"videojs-seek-buttons": "^2.2.0",
|
||||
"videojs-vtt-thumbnails-freetube": "^0.0.15",
|
||||
"videojs-vtt.js": "^0.15.4",
|
||||
"vite": "^2.9.13",
|
||||
"vite-plugin-compression": "^0.3.5",
|
||||
"vite-tsconfig-paths": "^3.3.17",
|
||||
|
||||
47
ui/v2.5/src/@types/landscape-fullscreen.d.ts
vendored
Normal file
47
ui/v2.5/src/@types/landscape-fullscreen.d.ts
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
declare module "videojs-landscape-fullscreen" {
|
||||
import videojs from "video.js";
|
||||
|
||||
function landscapeFullscreen(options?: {
|
||||
fullscreen: landscapeFullscreen.Options;
|
||||
}): void;
|
||||
|
||||
namespace landscapeFullscreen {
|
||||
const VERSION: typeof videojs.VERSION;
|
||||
|
||||
interface Options {
|
||||
/**
|
||||
* Enter fullscreen mode on rotating the device to landscape.
|
||||
* @default true
|
||||
*/
|
||||
enterOnRotate?: boolean;
|
||||
/**
|
||||
* Exit fullscreen mode on rotating the device to portrait.
|
||||
* @default true
|
||||
*/
|
||||
exitOnRotate?: boolean;
|
||||
/**
|
||||
* Always enter fullscreen in landscape mode even when device is in portrait mode (works on Chromium, Firefox, and IE >= 11).
|
||||
* @default true
|
||||
*/
|
||||
alwaysInLandscapeMode?: boolean;
|
||||
/**
|
||||
* Whether to use fake fullscreen on iOS (needed for displaying player controls instead of system controls).
|
||||
* @default true
|
||||
*/
|
||||
iOS?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export = landscapeFullscreen;
|
||||
|
||||
declare module "video.js" {
|
||||
interface VideoJsPlayer {
|
||||
landscapeFullscreen: typeof landscapeFullscreen;
|
||||
}
|
||||
interface VideoJsPlayerPluginOptions {
|
||||
landscapeFullscreen?: { fullscreen: landscapeFullscreen.Options };
|
||||
}
|
||||
}
|
||||
}
|
||||
111
ui/v2.5/src/@types/videojs-vtt.d.ts
vendored
Normal file
111
ui/v2.5/src/@types/videojs-vtt.d.ts
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
declare module "videojs-vtt.js" {
|
||||
namespace vttjs {
|
||||
/**
|
||||
* A custom JS error object that is reported through the parser's `onparsingerror` callback.
|
||||
* It has a name, code, and message property, along with all the regular properties that come with a JavaScript error object.
|
||||
*
|
||||
* There are two error codes that can be reported back currently:
|
||||
* * 0 BadSignature
|
||||
* * 1 BadTimeStamp
|
||||
*
|
||||
* Note: Exceptions other then ParsingError will be thrown and not reported.
|
||||
*/
|
||||
class ParsingError extends Error {
|
||||
readonly name: string;
|
||||
readonly code: number;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
namespace WebVTT {
|
||||
/**
|
||||
* A parser for the WebVTT spec in JavaScript.
|
||||
*/
|
||||
class Parser {
|
||||
/**
|
||||
* The Parser constructor is passed a window object with which it will create new `VTTCues` and `VTTRegions`
|
||||
* as well as an optional `StringDecoder` object which it will use to decode the data that the `parse()` function receives.
|
||||
* For ease of use, a `StringDecoder` is provided via `WebVTT.StringDecoder()`.
|
||||
* If a custom `StringDecoder` object is passed in it must support the API specified by the #whatwg string encoding spec.
|
||||
*
|
||||
* @param window the window object to use
|
||||
* @param vttjs the vtt.js module
|
||||
* @param decoder the decoder to decode `parse()` data with
|
||||
*/
|
||||
constructor(window: Window);
|
||||
constructor(window: Window, decoder: TextDecoder);
|
||||
constructor(window: Window, vttjs: vttjs, decoder: TextDecoder);
|
||||
|
||||
/**
|
||||
* Callback that is invoked for every region that is correctly parsed. Is passed a `VTTRegion` object.
|
||||
*/
|
||||
onregion?: (cue: VTTRegion) => void;
|
||||
|
||||
/**
|
||||
* Callback that is invoked for every cue that is fully parsed. In case of streaming parsing,
|
||||
* `oncue` is delayed until the cue has been completely received. Is passed a `VTTCue` object.
|
||||
*/
|
||||
oncue?: (cue: VTTCue) => void;
|
||||
|
||||
/**
|
||||
* Is invoked in response to `flush()` and after the content was parsed completely.
|
||||
*/
|
||||
onflush?: () => void;
|
||||
|
||||
/**
|
||||
* Is invoked when a parsing error has occurred. This means that some part of the WebVTT file markup is badly formed.
|
||||
* Is passed a `ParsingError` object.
|
||||
*/
|
||||
onparsingerror?: (e: ParsingError) => void;
|
||||
|
||||
/**
|
||||
* Hands data in some format to the parser for parsing. The passed data format is expected to be decodable by the
|
||||
* StringDecoder object that it has. The parser decodes the data and reassembles partial data (streaming), even across line breaks.
|
||||
*
|
||||
* @param data data to be parsed
|
||||
*/
|
||||
parse(data: string): this;
|
||||
|
||||
/**
|
||||
* Indicates that no more data is expected and will force the parser to parse any unparsed data that it may have.
|
||||
* Will also trigger `onflush`.
|
||||
*/
|
||||
flush(): this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to allow strings to be decoded instead of the default binary utf8 data.
|
||||
*/
|
||||
function StringDecoder(): TextDecoder;
|
||||
|
||||
/**
|
||||
* Parses the cue text handed to it into a tree of DOM nodes that mirrors the internal WebVTT node structure of the cue text.
|
||||
* It uses the window object handed to it to construct new HTMLElements and returns a tree of DOM nodes attached to a top level div.
|
||||
*
|
||||
* @param window window object to use
|
||||
* @param cuetext cue text to parse
|
||||
*/
|
||||
function convertCueToDOMTree(
|
||||
window: Window,
|
||||
cuetext: string
|
||||
): HTMLDivElement | null;
|
||||
|
||||
/**
|
||||
* Converts the cuetext of the cues passed to it to DOM trees - by calling convertCueToDOMTree - and then runs the
|
||||
* processing model steps of the WebVTT specification on the divs. The processing model applies the necessary CSS styles
|
||||
* to the cue divs to prepare them for display on the web page. During this process the cue divs get added to a block level element (overlay).
|
||||
* The overlay should be a part of the live DOM as the algorithm will use the computed styles (only of the divs) to do overlap avoidance.
|
||||
*
|
||||
* @param overlay A block level element (usually a div) that the computed cues and regions will be placed into.
|
||||
*/
|
||||
function processCues(
|
||||
window: Window,
|
||||
cues: VTTCue[],
|
||||
overlay: Element
|
||||
): void;
|
||||
}
|
||||
}
|
||||
|
||||
export = vttjs;
|
||||
}
|
||||
@@ -1,28 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import VideoJS, { VideoJsPlayer } from "video.js";
|
||||
import videojs, { VideoJsPlayer } from "video.js";
|
||||
|
||||
const Button = VideoJS.getComponent("Button");
|
||||
|
||||
interface ControlOptions extends VideoJS.ComponentOptions {
|
||||
interface ControlOptions extends videojs.ComponentOptions {
|
||||
direction: "forward" | "back";
|
||||
parent: SkipButtonPlugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* A video.js plugin.
|
||||
*
|
||||
* In the plugin function, the value of `this` is a video.js `Player`
|
||||
* instance. You cannot rely on the player being in a "ready" state here,
|
||||
* depending on how the plugin is invoked. This may or may not be important
|
||||
* to you; if not, remove the wait for "ready"!
|
||||
*
|
||||
* @function skipButtons
|
||||
* @param {Object} [options={}]
|
||||
* An object of options left to the plugin author to define.
|
||||
*/
|
||||
class SkipButtonPlugin extends VideoJS.getPlugin("plugin") {
|
||||
onNext?: () => void | undefined;
|
||||
onPrevious?: () => void | undefined;
|
||||
class SkipButtonPlugin extends videojs.getPlugin("plugin") {
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
|
||||
constructor(player: VideoJsPlayer) {
|
||||
super(player);
|
||||
@@ -74,7 +60,7 @@ class SkipButtonPlugin extends VideoJS.getPlugin("plugin") {
|
||||
}
|
||||
}
|
||||
|
||||
class SkipButton extends Button {
|
||||
class SkipButton extends videojs.getComponent("button") {
|
||||
private parentPlugin: SkipButtonPlugin;
|
||||
private direction: "forward" | "back";
|
||||
|
||||
@@ -107,12 +93,15 @@ class SkipButton extends Button {
|
||||
}
|
||||
}
|
||||
|
||||
VideoJS.registerComponent("SkipButton", SkipButton);
|
||||
VideoJS.registerPlugin("skipButtons", SkipButtonPlugin);
|
||||
videojs.registerComponent("SkipButton", SkipButton);
|
||||
videojs.registerPlugin("skipButtons", SkipButtonPlugin);
|
||||
|
||||
declare module "video.js" {
|
||||
interface VideoJsPlayer {
|
||||
skipButtons: () => void | SkipButtonPlugin;
|
||||
skipButtons: () => SkipButtonPlugin;
|
||||
}
|
||||
interface VideoJsPlayerPluginOptions {
|
||||
skipButtons?: {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, {
|
||||
useCallback,
|
||||
KeyboardEvent,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import VideoJS, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js";
|
||||
import "videojs-vtt-thumbnails-freetube";
|
||||
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js";
|
||||
import "videojs-seek-buttons";
|
||||
import "videojs-landscape-fullscreen";
|
||||
import "./live";
|
||||
@@ -16,6 +14,7 @@ import "./PlaylistButtons";
|
||||
import "./source-selector";
|
||||
import "./persist-volume";
|
||||
import "./markers";
|
||||
import "./vtt-thumbnails";
|
||||
import "./big-buttons";
|
||||
import cx from "classnames";
|
||||
|
||||
@@ -30,7 +29,7 @@ import { SceneInteractiveStatus } from "src/hooks/Interactive/status";
|
||||
import { languageMap } from "src/utils/caption";
|
||||
import { VIDEO_PLAYER_ID } from "./util";
|
||||
|
||||
function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) {
|
||||
function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) {
|
||||
function seekPercent(percent: number) {
|
||||
const duration = player.duration();
|
||||
const time = duration * percent;
|
||||
@@ -116,20 +115,24 @@ function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) {
|
||||
interface IScenePlayerProps {
|
||||
className?: string;
|
||||
scene: GQL.SceneDataFragment | undefined | null;
|
||||
timestamp: number;
|
||||
hideScrubberOverride: boolean;
|
||||
autoplay?: boolean;
|
||||
permitLoop?: boolean;
|
||||
onComplete?: () => void;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
initialTimestamp: number;
|
||||
sendSetTimestamp: (setTimestamp: (value: number) => void) => void;
|
||||
onComplete: () => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
}
|
||||
|
||||
export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
className,
|
||||
autoplay,
|
||||
scene,
|
||||
timestamp,
|
||||
hideScrubberOverride,
|
||||
autoplay,
|
||||
permitLoop = true,
|
||||
initialTimestamp: _initialTimestamp,
|
||||
sendSetTimestamp,
|
||||
onComplete,
|
||||
onNext,
|
||||
onPrevious,
|
||||
@@ -137,11 +140,11 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const config = configuration?.interface;
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const playerRef = useRef<VideoJsPlayer | undefined>();
|
||||
const sceneId = useRef<string | undefined>();
|
||||
const skipButtonsRef = useRef<any>();
|
||||
const playerRef = useRef<VideoJsPlayer>();
|
||||
const sceneId = useRef<string>();
|
||||
|
||||
const [time, setTime] = useState(0);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const {
|
||||
interactive: interactiveClient,
|
||||
@@ -151,9 +154,12 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
state: interactiveState,
|
||||
} = React.useContext(InteractiveContext);
|
||||
|
||||
const [initialTimestamp] = useState(timestamp);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [showScrubber, setShowScrubber] = useState(false);
|
||||
|
||||
const initialTimestamp = useRef(-1);
|
||||
const started = useRef(false);
|
||||
const auto = useRef(false);
|
||||
const interactiveReady = useRef(false);
|
||||
|
||||
const file = useMemo(
|
||||
@@ -162,11 +168,9 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
);
|
||||
|
||||
const maxLoopDuration = config?.maximumLoopDuration ?? 0;
|
||||
|
||||
const looping = useMemo(
|
||||
() =>
|
||||
!!file &&
|
||||
!!file.duration &&
|
||||
!!file?.duration &&
|
||||
permitLoop &&
|
||||
maxLoopDuration !== 0 &&
|
||||
file.duration < maxLoopDuration,
|
||||
@@ -174,26 +178,35 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (playerRef.current && timestamp >= 0) {
|
||||
if (hideScrubberOverride || fullscreen) {
|
||||
setShowScrubber(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
const show = window.innerHeight >= 450 && window.innerWidth >= 576;
|
||||
setShowScrubber(show);
|
||||
};
|
||||
onResize();
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [hideScrubberOverride, fullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
sendSetTimestamp((value: number) => {
|
||||
const player = playerRef.current;
|
||||
if (player && value >= 0) {
|
||||
player.play()?.then(() => {
|
||||
player.currentTime(timestamp);
|
||||
player.currentTime(value);
|
||||
});
|
||||
}
|
||||
}, [timestamp]);
|
||||
});
|
||||
}, [sendSetTimestamp]);
|
||||
|
||||
// Initialize VideoJS player
|
||||
useEffect(() => {
|
||||
if (playerRef.current) {
|
||||
const player = playerRef.current;
|
||||
player.loop(looping);
|
||||
interactiveClient.setLooping(looping);
|
||||
}
|
||||
}, [looping, interactiveClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement) return;
|
||||
|
||||
const options: VideoJsPlayerOptions = {
|
||||
controls: true,
|
||||
controlBar: {
|
||||
@@ -208,15 +221,29 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
inactivityTimeout: 2000,
|
||||
preload: "none",
|
||||
userActions: {
|
||||
hotkeys: function (event) {
|
||||
const player = this as VideoJsPlayer;
|
||||
handleHotkeys(player, event);
|
||||
hotkeys: function (this: VideoJsPlayer, event) {
|
||||
handleHotkeys(this, event);
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
vttThumbnails: {
|
||||
showTimestamp: true,
|
||||
},
|
||||
markers: {},
|
||||
sourceSelector: {},
|
||||
persistVolume: {},
|
||||
bigButtons: {},
|
||||
seekButtons: {
|
||||
forward: 10,
|
||||
back: 10,
|
||||
},
|
||||
skipButtons: {},
|
||||
},
|
||||
};
|
||||
|
||||
const player = VideoJS(videoElement, options);
|
||||
const player = videojs(videoRef.current!, options);
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const settings = (player as any).textTrackSettings;
|
||||
settings.setValues({
|
||||
backgroundColor: "#000",
|
||||
@@ -224,16 +251,24 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
});
|
||||
settings.updateDisplay();
|
||||
|
||||
(player as any).markers();
|
||||
(player as any).offset();
|
||||
(player as any).sourceSelector();
|
||||
(player as any).persistVolume();
|
||||
(player as any).bigButtons();
|
||||
|
||||
player.focus();
|
||||
playerRef.current = player;
|
||||
|
||||
// Video player destructor
|
||||
return () => {
|
||||
playerRef.current = undefined;
|
||||
player.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
const skipButtons = player.skipButtons();
|
||||
skipButtons.setForwardHandler(onNext);
|
||||
skipButtons.setBackwardHandler(onPrevious);
|
||||
}, [onNext, onPrevious]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scene?.interactive && interactiveInitialised) {
|
||||
interactiveReady.current = false;
|
||||
@@ -248,156 +283,144 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
scene?.paths.funscript,
|
||||
]);
|
||||
|
||||
// Player event handlers
|
||||
useEffect(() => {
|
||||
if (skipButtonsRef.current) {
|
||||
skipButtonsRef.current.setForwardHandler(onNext);
|
||||
skipButtonsRef.current.setBackwardHandler(onPrevious);
|
||||
function canplay(this: VideoJsPlayer) {
|
||||
if (initialTimestamp.current !== -1) {
|
||||
this.currentTime(initialTimestamp.current);
|
||||
initialTimestamp.current = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function playing(this: VideoJsPlayer) {
|
||||
// This still runs even if autoplay failed on Safari,
|
||||
// only set flag if actually playing
|
||||
if (!started.current && !this.paused()) {
|
||||
started.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
function loadstart(this: VideoJsPlayer) {
|
||||
setReady(true);
|
||||
}
|
||||
|
||||
function fullscreenchange(this: VideoJsPlayer) {
|
||||
setFullscreen(this.isFullscreen());
|
||||
}
|
||||
}, [onNext, onPrevious]);
|
||||
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
if (player) {
|
||||
player.seekButtons({
|
||||
forward: 10,
|
||||
back: 10,
|
||||
});
|
||||
if (!player) return;
|
||||
|
||||
skipButtonsRef.current = player.skipButtons() ?? undefined;
|
||||
player.on("canplay", canplay);
|
||||
player.on("playing", playing);
|
||||
player.on("loadstart", loadstart);
|
||||
player.on("fullscreenchange", fullscreenchange);
|
||||
|
||||
player.focus();
|
||||
}
|
||||
|
||||
// Video player destructor
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.dispose();
|
||||
playerRef.current = undefined;
|
||||
}
|
||||
player.off("canplay", canplay);
|
||||
player.off("playing", playing);
|
||||
player.off("loadstart", loadstart);
|
||||
player.off("fullscreenchange", fullscreenchange);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
function onplay(this: VideoJsPlayer) {
|
||||
this.persistVolume().enabled = true;
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
interactiveClient.play(this.currentTime());
|
||||
}
|
||||
}
|
||||
|
||||
function pause(this: VideoJsPlayer) {
|
||||
interactiveClient.pause();
|
||||
}
|
||||
|
||||
function seeking(this: VideoJsPlayer) {
|
||||
if (this.paused()) return;
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
interactiveClient.play(this.currentTime());
|
||||
}
|
||||
}
|
||||
|
||||
function timeupdate(this: VideoJsPlayer) {
|
||||
if (this.paused()) return;
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
interactiveClient.ensurePlaying(this.currentTime());
|
||||
}
|
||||
setTime(this.currentTime());
|
||||
}
|
||||
|
||||
const start = useCallback(() => {
|
||||
const player = playerRef.current;
|
||||
if (player && scene) {
|
||||
started.current = true;
|
||||
if (!player) return;
|
||||
|
||||
player
|
||||
.play()
|
||||
?.then(() => {
|
||||
if (initialTimestamp > 0) {
|
||||
player.currentTime(initialTimestamp);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (scene.paths.screenshot) player.poster(scene.paths.screenshot);
|
||||
});
|
||||
}
|
||||
}, [scene, initialTimestamp]);
|
||||
player.on("play", onplay);
|
||||
player.on("pause", pause);
|
||||
player.on("seeking", seeking);
|
||||
player.on("timeupdate", timeupdate);
|
||||
|
||||
return () => {
|
||||
player.off("play", onplay);
|
||||
player.off("pause", pause);
|
||||
player.off("seeking", seeking);
|
||||
player.off("timeupdate", timeupdate);
|
||||
};
|
||||
}, [interactiveClient, scene]);
|
||||
|
||||
useEffect(() => {
|
||||
let prevCaptionOffset = 0;
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
function addCaptionOffset(player: VideoJsPlayer, offset: number) {
|
||||
const tracks = player.remoteTextTracks();
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const track = tracks[i];
|
||||
const { cues } = track;
|
||||
if (cues) {
|
||||
for (let j = 0; j < cues.length; j++) {
|
||||
const cue = cues[j];
|
||||
cue.startTime = cue.startTime + offset;
|
||||
cue.endTime = cue.endTime + offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
// don't re-initialise the player unless the scene has changed
|
||||
if (!scene || !file || scene.id === sceneId.current) return;
|
||||
sceneId.current = scene.id;
|
||||
|
||||
setReady(false);
|
||||
|
||||
// always stop the interactive client on initialisation
|
||||
interactiveClient.pause();
|
||||
interactiveReady.current = false;
|
||||
|
||||
const isLandscape = file.height && file.width && file.width > file.height;
|
||||
|
||||
if (isLandscape) {
|
||||
player.landscapeFullscreen({
|
||||
fullscreen: {
|
||||
enterOnRotate: true,
|
||||
exitOnRotate: true,
|
||||
alwaysInLandscapeMode: true,
|
||||
iOS: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function removeCaptionOffset(player: VideoJsPlayer, offset: number) {
|
||||
const tracks = player.remoteTextTracks();
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const track = tracks[i];
|
||||
const { cues } = track;
|
||||
if (cues) {
|
||||
for (let j = 0; j < cues.length; j++) {
|
||||
const cue = cues[j];
|
||||
cue.startTime = cue.startTime + prevCaptionOffset - offset;
|
||||
cue.endTime = cue.endTime + prevCaptionOffset - offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOffset(player: VideoJsPlayer) {
|
||||
if (!scene || !file) return;
|
||||
|
||||
const currentSrc = new URL(player.currentSrc());
|
||||
|
||||
const { duration } = file;
|
||||
const sourceSelector = player.sourceSelector();
|
||||
sourceSelector.setSources(
|
||||
scene.sceneStreams.map((stream) => {
|
||||
const isDirect =
|
||||
currentSrc.pathname.endsWith("/stream") ||
|
||||
currentSrc.pathname.endsWith("/stream.m3u8");
|
||||
stream.url.endsWith("/stream") || stream.url.endsWith("/stream.m3u8");
|
||||
|
||||
const curTime = player.currentTime();
|
||||
if (!isDirect) {
|
||||
(player as any).setOffsetDuration(file.duration);
|
||||
} else {
|
||||
(player as any).clearOffsetDuration();
|
||||
}
|
||||
return {
|
||||
src: stream.url,
|
||||
type: stream.mime_type ?? undefined,
|
||||
label: stream.label ?? undefined,
|
||||
offset: !isDirect,
|
||||
duration,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (curTime != prevCaptionOffset) {
|
||||
if (!isDirect) {
|
||||
removeCaptionOffset(player, curTime);
|
||||
prevCaptionOffset = curTime;
|
||||
} else {
|
||||
if (prevCaptionOffset != 0) {
|
||||
addCaptionOffset(player, prevCaptionOffset);
|
||||
prevCaptionOffset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(play: boolean) {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
const currentFile = player.currentSource();
|
||||
if (currentFile) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Source failed: ${currentFile.src}`);
|
||||
player.focus();
|
||||
}
|
||||
|
||||
if (tryNextStream()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Trying next source in playlist: ${player.currentSrc()}`);
|
||||
player.load();
|
||||
if (play) {
|
||||
player.play();
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("No more sources in playlist.");
|
||||
}
|
||||
}
|
||||
|
||||
function tryNextStream() {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
const sources = player.currentSources();
|
||||
|
||||
if (sources.length > 1) {
|
||||
sources.shift();
|
||||
player.src(sources);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
const markers = player.markers();
|
||||
markers.clearMarkers();
|
||||
for (const marker of scene.scene_markers) {
|
||||
markers.addMarker({
|
||||
title: marker.title,
|
||||
time: marker.seconds,
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultLanguageCode() {
|
||||
var languageCode = window.navigator.language;
|
||||
let languageCode = window.navigator.language;
|
||||
|
||||
if (languageCode.indexOf("-") !== -1) {
|
||||
languageCode = languageCode.split("-")[0];
|
||||
@@ -410,270 +433,155 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
return languageCode;
|
||||
}
|
||||
|
||||
function loadCaptions(player: VideoJsPlayer) {
|
||||
if (!scene) return;
|
||||
|
||||
if (scene.captions) {
|
||||
var languageCode = getDefaultLanguageCode();
|
||||
var hasDefault = false;
|
||||
if (scene.captions && scene.captions.length > 0) {
|
||||
const languageCode = getDefaultLanguageCode();
|
||||
let hasDefault = false;
|
||||
|
||||
for (let caption of scene.captions) {
|
||||
var lang = caption.language_code;
|
||||
var label = lang;
|
||||
const lang = caption.language_code;
|
||||
let label = lang;
|
||||
if (languageMap.has(lang)) {
|
||||
label = languageMap.get(lang)!;
|
||||
}
|
||||
|
||||
label = label + " (" + caption.caption_type + ")";
|
||||
var setAsDefault = !hasDefault && languageCode == lang;
|
||||
if (!hasDefault && setAsDefault) {
|
||||
const setAsDefault = !hasDefault && languageCode == lang;
|
||||
if (setAsDefault) {
|
||||
hasDefault = true;
|
||||
}
|
||||
player.addRemoteTextTrack(
|
||||
sourceSelector.addTextTrack(
|
||||
{
|
||||
src:
|
||||
scene.paths.caption +
|
||||
"?lang=" +
|
||||
lang +
|
||||
"&type=" +
|
||||
caption.caption_type,
|
||||
src: `${scene.paths.caption}?lang=${lang}&type=${caption.caption_type}`,
|
||||
kind: "captions",
|
||||
srclang: lang,
|
||||
label: label,
|
||||
default: setAsDefault,
|
||||
},
|
||||
true
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.paths.screenshot) {
|
||||
player.poster(scene.paths.screenshot);
|
||||
} else {
|
||||
player.poster("");
|
||||
}
|
||||
|
||||
function loadstart(this: VideoJsPlayer) {
|
||||
// handle offset after loading so that we get the correct current source
|
||||
handleOffset(this);
|
||||
}
|
||||
auto.current =
|
||||
autoplay || (config?.autostartVideo ?? false) || _initialTimestamp > 0;
|
||||
|
||||
function onPlay(this: VideoJsPlayer) {
|
||||
this.poster("");
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
interactiveClient.play(this.currentTime());
|
||||
}
|
||||
}
|
||||
initialTimestamp.current = _initialTimestamp;
|
||||
setTime(_initialTimestamp);
|
||||
|
||||
function pause() {
|
||||
player.load();
|
||||
player.focus();
|
||||
|
||||
player.ready(() => {
|
||||
player.vttThumbnails().src(scene.paths.vtt ?? null);
|
||||
});
|
||||
|
||||
started.current = false;
|
||||
|
||||
return () => {
|
||||
// stop the interactive client
|
||||
interactiveClient.pause();
|
||||
};
|
||||
}, [
|
||||
file,
|
||||
scene,
|
||||
interactiveClient,
|
||||
autoplay,
|
||||
config?.autostartVideo,
|
||||
_initialTimestamp,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
player.loop(looping);
|
||||
interactiveClient.setLooping(looping);
|
||||
}, [interactiveClient, looping]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scene || !ready || !auto.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
function timeupdate(this: VideoJsPlayer) {
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
interactiveClient.ensurePlaying(this.currentTime());
|
||||
}
|
||||
setTime(this.currentTime());
|
||||
}
|
||||
|
||||
function seeking(this: VideoJsPlayer) {
|
||||
this.play();
|
||||
}
|
||||
|
||||
function error() {
|
||||
handleError(true);
|
||||
}
|
||||
|
||||
// changing source (eg when seeking) resets the playback rate
|
||||
// so set the default in addition to the current rate
|
||||
function ratechange(this: VideoJsPlayer) {
|
||||
this.defaultPlaybackRate(this.playbackRate());
|
||||
}
|
||||
|
||||
function loadedmetadata(this: VideoJsPlayer) {
|
||||
if (!this.videoWidth() && !this.videoHeight()) {
|
||||
// Occurs during preload when videos with supported audio/unsupported video are preloaded.
|
||||
// Treat this as a decoding error and try the next source without playing.
|
||||
// However on Safari we get an media event when m3u8 is loaded which needs to be ignored.
|
||||
const currentFile = this.currentSrc();
|
||||
if (currentFile != null && !currentFile.includes("m3u8")) {
|
||||
// const play = !player.paused();
|
||||
// handleError(play);
|
||||
this.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
||||
}
|
||||
}
|
||||
// check if we're waiting for the interactive client
|
||||
if (
|
||||
scene.interactive &&
|
||||
interactiveClient.handyKey &&
|
||||
currentScript !== scene.paths.funscript
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
// always initialise event handlers since these are destroyed when the
|
||||
// component is destroyed
|
||||
player.on("loadstart", loadstart);
|
||||
player.on("play", onPlay);
|
||||
player.on("pause", pause);
|
||||
player.on("timeupdate", timeupdate);
|
||||
player.on("seeking", seeking);
|
||||
player.on("error", error);
|
||||
player.on("ratechange", ratechange);
|
||||
player.on("loadedmetadata", loadedmetadata);
|
||||
player.play()?.catch(() => {
|
||||
// Browser probably blocking non-muted autoplay, so mute and try again
|
||||
player.persistVolume().enabled = false;
|
||||
player.muted(true);
|
||||
|
||||
// don't re-initialise the player unless the scene has changed
|
||||
if (!scene || !file || scene.id === sceneId.current) return;
|
||||
sceneId.current = scene.id;
|
||||
|
||||
// always stop the interactive client on initialisation
|
||||
interactiveClient.pause();
|
||||
interactiveReady.current = false;
|
||||
|
||||
const auto =
|
||||
autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0;
|
||||
if (!auto && scene.paths?.screenshot) player.poster(scene.paths.screenshot);
|
||||
else player.poster("");
|
||||
|
||||
const isLandscape = file.height && file.width && file.width > file.height;
|
||||
|
||||
if (isLandscape) {
|
||||
(player as any).landscapeFullscreen({
|
||||
fullscreen: {
|
||||
enterOnRotate: true,
|
||||
exitOnRotate: true,
|
||||
alwaysInLandscapeMode: true,
|
||||
iOS: false,
|
||||
},
|
||||
player.play();
|
||||
});
|
||||
}
|
||||
|
||||
// clear the offset before loading anything new.
|
||||
// otherwise, the offset will be applied to the next file when
|
||||
// currentTime is called.
|
||||
(player as any).clearOffsetDuration();
|
||||
|
||||
const tracks = player.remoteTextTracks();
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
player.removeRemoteTextTrack(tracks[i] as any);
|
||||
}
|
||||
|
||||
player.src(
|
||||
scene.sceneStreams.map((stream) => ({
|
||||
src: stream.url,
|
||||
type: stream.mime_type ?? undefined,
|
||||
label: stream.label ?? undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
if (scene.paths.chapters_vtt) {
|
||||
player.addRemoteTextTrack(
|
||||
{
|
||||
src: scene.paths.chapters_vtt,
|
||||
kind: "chapters",
|
||||
default: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (scene.captions?.length! > 0) {
|
||||
loadCaptions(player);
|
||||
}
|
||||
|
||||
player.currentTime(0);
|
||||
|
||||
player.loop(looping);
|
||||
interactiveClient.setLooping(looping);
|
||||
|
||||
player.load();
|
||||
player.focus();
|
||||
|
||||
if ((player as any).vttThumbnails?.src)
|
||||
(player as any).vttThumbnails?.src(scene?.paths.vtt);
|
||||
else
|
||||
(player as any).vttThumbnails({
|
||||
src: scene?.paths.vtt,
|
||||
showTimestamp: true,
|
||||
});
|
||||
|
||||
setReady(true);
|
||||
started.current = false;
|
||||
|
||||
return () => {
|
||||
setReady(false);
|
||||
|
||||
// stop the interactive client
|
||||
interactiveClient.pause();
|
||||
|
||||
player.off("loadstart", loadstart);
|
||||
player.off("play", onPlay);
|
||||
player.off("pause", pause);
|
||||
player.off("timeupdate", timeupdate);
|
||||
player.off("seeking", seeking);
|
||||
player.off("error", error);
|
||||
player.off("ratechange", ratechange);
|
||||
player.off("loadedmetadata", loadedmetadata);
|
||||
};
|
||||
}, [
|
||||
scene,
|
||||
file,
|
||||
config?.autostartVideo,
|
||||
looping,
|
||||
initialTimestamp,
|
||||
autoplay,
|
||||
interactiveClient,
|
||||
start,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || started.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto =
|
||||
autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0;
|
||||
|
||||
// check if we're waiting for the interactive client
|
||||
const interactiveWaiting =
|
||||
scene?.interactive &&
|
||||
interactiveClient.handyKey &&
|
||||
currentScript !== scene.paths.funscript;
|
||||
|
||||
if (scene && auto && !interactiveWaiting) {
|
||||
start();
|
||||
}
|
||||
}, [
|
||||
config?.autostartVideo,
|
||||
initialTimestamp,
|
||||
scene,
|
||||
ready,
|
||||
interactiveClient,
|
||||
currentScript,
|
||||
autoplay,
|
||||
start,
|
||||
]);
|
||||
auto.current = false;
|
||||
}, [scene, ready, interactiveClient, currentScript]);
|
||||
|
||||
useEffect(() => {
|
||||
// Attach handler for onComplete event
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
player.on("ended", () => {
|
||||
onComplete?.();
|
||||
});
|
||||
player.on("ended", onComplete);
|
||||
|
||||
return () => player.off("ended");
|
||||
}, [onComplete]);
|
||||
|
||||
const onScrubberScrolled = () => {
|
||||
const onScrubberScroll = () => {
|
||||
if (started.current) {
|
||||
playerRef.current?.pause();
|
||||
};
|
||||
const onScrubberSeek = (seconds: number) => {
|
||||
const player = playerRef.current;
|
||||
if (player) {
|
||||
player.play()?.then(() => {
|
||||
player.currentTime(seconds);
|
||||
});
|
||||
}
|
||||
};
|
||||
const onScrubberSeek = (seconds: number) => {
|
||||
if (started.current) {
|
||||
playerRef.current?.currentTime(seconds);
|
||||
} else {
|
||||
initialTimestamp.current = seconds;
|
||||
setTime(seconds);
|
||||
}
|
||||
};
|
||||
|
||||
// Override spacebar to always pause/play
|
||||
function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key == " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (player.paused()) {
|
||||
player.play();
|
||||
} else {
|
||||
player.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isPortrait =
|
||||
scene && file && file.height && file.width && file.height > file.width;
|
||||
|
||||
return (
|
||||
<div className={cx("VideoPlayer", { portrait: isPortrait })}>
|
||||
<div
|
||||
className={cx("VideoPlayer", { portrait: isPortrait })}
|
||||
onKeyDownCapture={onKeyDown}
|
||||
>
|
||||
<div data-vjs-player className={cx("video-wrapper", className)}>
|
||||
<video
|
||||
playsInline
|
||||
@@ -685,13 +593,13 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
{scene?.interactive &&
|
||||
(interactiveState !== ConnectionState.Ready ||
|
||||
playerRef.current?.paused()) && <SceneInteractiveStatus />}
|
||||
{scene && file && (
|
||||
{scene && file && showScrubber && (
|
||||
<ScenePlayerScrubber
|
||||
file={file}
|
||||
scene={scene}
|
||||
position={time}
|
||||
time={time}
|
||||
onSeek={onScrubberSeek}
|
||||
onScrolled={onScrubberScrolled}
|
||||
onScroll={onScrubberScroll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
|
||||
import React, {
|
||||
CSSProperties,
|
||||
useEffect,
|
||||
@@ -11,16 +9,23 @@ import { Button } from "react-bootstrap";
|
||||
import axios from "axios";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { WebVTT } from "videojs-vtt.js";
|
||||
|
||||
interface IScenePlayerScrubberProps {
|
||||
file: GQL.VideoFileDataFragment;
|
||||
scene: GQL.SceneDataFragment;
|
||||
position: number;
|
||||
time: number;
|
||||
onSeek: (seconds: number) => void;
|
||||
onScrolled: () => void;
|
||||
onScroll: () => void;
|
||||
}
|
||||
|
||||
interface ISceneSpriteItem {
|
||||
style: CSSProperties;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface ISceneSpriteInfo {
|
||||
url: string;
|
||||
start: number;
|
||||
end: number;
|
||||
x: number;
|
||||
@@ -32,284 +37,280 @@ interface ISceneSpriteItem {
|
||||
async function fetchSpriteInfo(vttPath: string) {
|
||||
const response = await axios.get<string>(vttPath, { responseType: "text" });
|
||||
|
||||
// TODO: This is gnarly
|
||||
const lines = response.data.split("\n");
|
||||
if (lines.shift() !== "WEBVTT") {
|
||||
return;
|
||||
}
|
||||
if (lines.shift() !== "") {
|
||||
return;
|
||||
}
|
||||
let item: ISceneSpriteItem = { start: 0, end: 0, x: 0, y: 0, w: 0, h: 0 };
|
||||
const newSpriteItems: ISceneSpriteItem[] = [];
|
||||
while (lines.length) {
|
||||
const line = lines.shift();
|
||||
if (line !== undefined) {
|
||||
if (line.includes("#") && line.includes("=") && line.includes(",")) {
|
||||
const size = line.split("#")[1].split("=")[1].split(",");
|
||||
item.x = Number(size[0]);
|
||||
item.y = Number(size[1]);
|
||||
item.w = Number(size[2]);
|
||||
item.h = Number(size[3]);
|
||||
const sprites: ISceneSpriteInfo[] = [];
|
||||
|
||||
newSpriteItems.push(item);
|
||||
item = { start: 0, end: 0, x: 0, y: 0, w: 0, h: 0 };
|
||||
} else if (line.includes(" --> ")) {
|
||||
const times = line.split(" --> ");
|
||||
const parser = new WebVTT.Parser(window, WebVTT.StringDecoder());
|
||||
parser.oncue = (cue: VTTCue) => {
|
||||
const match = cue.text.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i);
|
||||
if (!match) return;
|
||||
|
||||
const start = times[0].split(":");
|
||||
item.start = +start[0] * 60 * 60 + +start[1] * 60 + +start[2];
|
||||
sprites.push({
|
||||
url: new URL(match[1], vttPath).href,
|
||||
start: cue.startTime,
|
||||
end: cue.endTime,
|
||||
x: Number(match[2]),
|
||||
y: Number(match[3]),
|
||||
w: Number(match[4]),
|
||||
h: Number(match[5]),
|
||||
});
|
||||
};
|
||||
parser.parse(response.data);
|
||||
parser.flush();
|
||||
|
||||
const end = times[1].split(":");
|
||||
item.end = +end[0] * 60 * 60 + +end[1] * 60 + +end[2];
|
||||
}
|
||||
}
|
||||
return sprites;
|
||||
}
|
||||
|
||||
return newSpriteItems;
|
||||
}
|
||||
|
||||
export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
|
||||
props: IScenePlayerScrubberProps
|
||||
) => {
|
||||
export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = ({
|
||||
file,
|
||||
scene,
|
||||
time,
|
||||
onSeek,
|
||||
onScroll,
|
||||
}) => {
|
||||
const contentEl = useRef<HTMLDivElement>(null);
|
||||
const positionIndicatorEl = useRef<HTMLDivElement>(null);
|
||||
const scrubberSliderEl = useRef<HTMLDivElement>(null);
|
||||
const indicatorEl = useRef<HTMLDivElement>(null);
|
||||
const sliderEl = useRef<HTMLDivElement>(null);
|
||||
const mouseDown = useRef(false);
|
||||
const lastMouseEvent = useRef<MouseEvent | null>(null);
|
||||
const startMouseEvent = useRef<MouseEvent | null>(null);
|
||||
const velocity = useRef(0);
|
||||
|
||||
const _position = useRef(0);
|
||||
const getPosition = useCallback(() => _position.current, []);
|
||||
const prevTime = useRef(NaN);
|
||||
const _width = useRef(0);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [scrubWidth, setScrubWidth] = useState(0);
|
||||
const position = useRef(0);
|
||||
const setPosition = useCallback(
|
||||
(newPostion: number, shouldEmit: boolean = true) => {
|
||||
if (!scrubberSliderEl.current || !positionIndicatorEl.current) {
|
||||
return;
|
||||
}
|
||||
if (shouldEmit) {
|
||||
props.onScrolled();
|
||||
}
|
||||
(value: number, seek: boolean) => {
|
||||
if (!scrubWidth) return;
|
||||
|
||||
const midpointOffset = scrubberSliderEl.current.clientWidth / 2;
|
||||
const slider = sliderEl.current!;
|
||||
const indicator = indicatorEl.current!;
|
||||
|
||||
const bounds = getBounds() * -1;
|
||||
if (newPostion > midpointOffset) {
|
||||
_position.current = midpointOffset;
|
||||
} else if (newPostion < bounds - midpointOffset) {
|
||||
_position.current = bounds - midpointOffset;
|
||||
const midpointOffset = slider.clientWidth / 2;
|
||||
|
||||
let newPosition: number;
|
||||
let percentage: number;
|
||||
if (value >= midpointOffset) {
|
||||
percentage = 0;
|
||||
newPosition = midpointOffset;
|
||||
} else if (value <= midpointOffset - scrubWidth) {
|
||||
percentage = 1;
|
||||
newPosition = midpointOffset - scrubWidth;
|
||||
} else {
|
||||
_position.current = newPostion;
|
||||
percentage = (midpointOffset - value) / scrubWidth;
|
||||
newPosition = value;
|
||||
}
|
||||
|
||||
scrubberSliderEl.current.style.transform = `translateX(${_position.current}px)`;
|
||||
slider.style.transform = `translateX(${newPosition}px)`;
|
||||
indicator.style.transform = `translateX(${percentage * 100}%)`;
|
||||
|
||||
const indicatorPosition =
|
||||
((newPostion - midpointOffset) / (bounds - midpointOffset * 2)) *
|
||||
scrubberSliderEl.current.clientWidth;
|
||||
positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`;
|
||||
position.current = newPosition;
|
||||
|
||||
if (seek) {
|
||||
onSeek(percentage * (file.duration || 0));
|
||||
}
|
||||
},
|
||||
[props]
|
||||
[onSeek, file.duration, scrubWidth]
|
||||
);
|
||||
|
||||
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]);
|
||||
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrubberSliderEl.current) {
|
||||
return;
|
||||
}
|
||||
scrubberSliderEl.current.style.transform = `translateX(${
|
||||
scrubberSliderEl.current.clientWidth / 2
|
||||
}px)`;
|
||||
}, [scrubberSliderEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.scene.paths.vtt) return;
|
||||
fetchSpriteInfo(props.scene.paths.vtt).then((sprites) => {
|
||||
if (sprites) setSpriteItems(sprites);
|
||||
});
|
||||
}, [props.scene]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrubberSliderEl.current) {
|
||||
return;
|
||||
}
|
||||
const duration = Number(props.file.duration);
|
||||
const percentage = props.position / duration;
|
||||
const position =
|
||||
(scrubberSliderEl.current.scrollWidth * percentage -
|
||||
scrubberSliderEl.current.clientWidth / 2) *
|
||||
-1;
|
||||
setPosition(position, false);
|
||||
}, [props.position, props.file.duration, setPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("mouseup", onMouseUp, false);
|
||||
return () => {
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
if (!scene.paths.vtt) return;
|
||||
fetchSpriteInfo(scene.paths.vtt).then((sprites) => {
|
||||
if (!sprites) return;
|
||||
let totalWidth = 0;
|
||||
const newSprites = sprites?.map((sprite, index) => {
|
||||
totalWidth += sprite.w;
|
||||
const left = sprite.w * index;
|
||||
const style = {
|
||||
width: `${sprite.w}px`,
|
||||
height: `${sprite.h}px`,
|
||||
backgroundPosition: `${-sprite.x}px ${-sprite.y}px`,
|
||||
backgroundImage: `url(${sprite.url})`,
|
||||
left: `${left}px`,
|
||||
};
|
||||
const start = TextUtils.secondsToTimestamp(sprite.start);
|
||||
const end = TextUtils.secondsToTimestamp(sprite.end);
|
||||
return {
|
||||
style,
|
||||
time: `${start} - ${end}`,
|
||||
};
|
||||
});
|
||||
setScrubWidth(totalWidth);
|
||||
setSpriteItems(newSprites);
|
||||
});
|
||||
}, [scene]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentEl.current) {
|
||||
return;
|
||||
const onResize = (entries: ResizeObserverEntry[]) => {
|
||||
const newWidth = entries[0].target.clientWidth;
|
||||
if (_width.current != newWidth) {
|
||||
// set prevTime to NaN to not use a transition when updating the slider position
|
||||
prevTime.current = NaN;
|
||||
_width.current = newWidth;
|
||||
setWidth(newWidth);
|
||||
}
|
||||
const el = contentEl.current;
|
||||
el.addEventListener("mousedown", onMouseDown, false);
|
||||
return () => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.removeEventListener("mousedown", onMouseDown);
|
||||
};
|
||||
});
|
||||
|
||||
const content = contentEl.current!;
|
||||
const resizeObserver = new ResizeObserver(onResize);
|
||||
resizeObserver.observe(content);
|
||||
|
||||
return () => {
|
||||
resizeObserver.unobserve(content);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function setLinearTransition() {
|
||||
const slider = sliderEl.current!;
|
||||
slider.style.transition = "500ms linear";
|
||||
}
|
||||
|
||||
function setEaseOutTransition() {
|
||||
const slider = sliderEl.current!;
|
||||
slider.style.transition = "333ms ease-out";
|
||||
}
|
||||
|
||||
function clearTransition() {
|
||||
const slider = sliderEl.current!;
|
||||
slider.style.transition = "";
|
||||
}
|
||||
|
||||
// Update slider position when player time changes
|
||||
useEffect(() => {
|
||||
if (!contentEl.current) {
|
||||
return;
|
||||
}
|
||||
const el = contentEl.current;
|
||||
el.addEventListener("mousemove", onMouseMove, false);
|
||||
return () => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.removeEventListener("mousemove", onMouseMove);
|
||||
};
|
||||
});
|
||||
if (!scrubWidth || !width) return;
|
||||
|
||||
function onMouseUp(this: Window, event: MouseEvent) {
|
||||
if (!startMouseEvent.current || !scrubberSliderEl.current) {
|
||||
return;
|
||||
const duration = Number(file.duration);
|
||||
const percentage = time / duration;
|
||||
const newPosition = width / 2 - percentage * scrubWidth;
|
||||
|
||||
// Ignore position changes of < 1px
|
||||
if (Math.abs(newPosition - position.current) < 1) return;
|
||||
|
||||
const delta = Math.abs(time - prevTime.current);
|
||||
if (isNaN(delta)) {
|
||||
// Don't use a transition on initial time change or after resize
|
||||
clearTransition();
|
||||
} else if (delta <= 1) {
|
||||
// If time changed by < 1s, use linear transition instead of ease-out
|
||||
setLinearTransition();
|
||||
} else {
|
||||
setEaseOutTransition();
|
||||
}
|
||||
prevTime.current = time;
|
||||
|
||||
setPosition(newPosition, false);
|
||||
}, [file.duration, setPosition, time, width, scrubWidth]);
|
||||
|
||||
const onMouseUp = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!mouseDown.current) return;
|
||||
const slider = sliderEl.current!;
|
||||
|
||||
mouseDown.current = false;
|
||||
const delta = Math.abs(event.clientX - startMouseEvent.current.clientX);
|
||||
|
||||
let newPosition = position.current;
|
||||
const midpointOffset = slider.clientWidth / 2;
|
||||
const delta = Math.abs(event.clientX - startMouseEvent.current!.clientX);
|
||||
if (delta < 1 && event.target instanceof HTMLDivElement) {
|
||||
const { target } = event;
|
||||
let seekSeconds: number | undefined;
|
||||
|
||||
const spriteIdString = target.getAttribute("data-sprite-item-id");
|
||||
if (spriteIdString != null) {
|
||||
const spritePercentage = event.offsetX / target.clientWidth;
|
||||
const offset =
|
||||
target.offsetLeft + target.clientWidth * spritePercentage;
|
||||
const percentage = offset / scrubberSliderEl.current.scrollWidth;
|
||||
seekSeconds = percentage * (props.file.duration || 0);
|
||||
if (target.hasAttribute("data-sprite-item-id")) {
|
||||
newPosition = midpointOffset - (target.offsetLeft + event.offsetX);
|
||||
}
|
||||
|
||||
const markerIdString = target.getAttribute("data-marker-id");
|
||||
if (markerIdString != null) {
|
||||
const marker = props.scene.scene_markers[Number(markerIdString)];
|
||||
seekSeconds = marker.seconds;
|
||||
if (target.hasAttribute("data-marker-id")) {
|
||||
newPosition = midpointOffset - target.offsetLeft;
|
||||
}
|
||||
|
||||
if (seekSeconds) {
|
||||
props.onSeek(seekSeconds);
|
||||
}
|
||||
} else if (Math.abs(velocity.current) > 25) {
|
||||
const newPosition = getPosition() + velocity.current * 10;
|
||||
setPosition(newPosition);
|
||||
if (Math.abs(velocity.current) > 25) {
|
||||
newPosition = position.current + velocity.current * 10;
|
||||
velocity.current = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseDown(this: HTMLDivElement, event: MouseEvent) {
|
||||
setEaseOutTransition();
|
||||
setPosition(newPosition, true);
|
||||
},
|
||||
[setPosition]
|
||||
);
|
||||
|
||||
const onMouseDown = useCallback((event: MouseEvent) => {
|
||||
// Only if left mouse button pressed
|
||||
if (event.button !== 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
mouseDown.current = true;
|
||||
lastMouseEvent.current = event;
|
||||
startMouseEvent.current = event;
|
||||
velocity.current = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
function onMouseMove(this: HTMLDivElement, event: MouseEvent) {
|
||||
if (!mouseDown.current) {
|
||||
return;
|
||||
const onMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!mouseDown.current) return;
|
||||
|
||||
if (lastMouseEvent.current === startMouseEvent.current) {
|
||||
onScroll();
|
||||
}
|
||||
|
||||
// negative dragging right (past), positive left (future)
|
||||
const delta = event.clientX - (lastMouseEvent.current?.clientX ?? 0);
|
||||
const delta = event.clientX - lastMouseEvent.current!.clientX;
|
||||
|
||||
const movement = event.movementX;
|
||||
velocity.current = movement;
|
||||
|
||||
const newPostion = getPosition() + delta;
|
||||
setPosition(newPostion);
|
||||
clearTransition();
|
||||
setPosition(position.current + delta, false);
|
||||
lastMouseEvent.current = event;
|
||||
}
|
||||
|
||||
function getBounds(): number {
|
||||
if (!scrubberSliderEl.current || !positionIndicatorEl.current) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
scrubberSliderEl.current.scrollWidth -
|
||||
scrubberSliderEl.current.clientWidth
|
||||
},
|
||||
[onScroll, setPosition]
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const content = contentEl.current!;
|
||||
|
||||
content.addEventListener("mousedown", onMouseDown, false);
|
||||
content.addEventListener("mousemove", onMouseMove, false);
|
||||
window.addEventListener("mouseup", onMouseUp, false);
|
||||
|
||||
return () => {
|
||||
content.removeEventListener("mousedown", onMouseDown);
|
||||
content.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [onMouseDown, onMouseMove, onMouseUp]);
|
||||
|
||||
function goBack() {
|
||||
if (!scrubberSliderEl.current) {
|
||||
return;
|
||||
}
|
||||
const newPosition = getPosition() + scrubberSliderEl.current.clientWidth;
|
||||
setPosition(newPosition);
|
||||
const slider = sliderEl.current!;
|
||||
const newPosition = position.current + slider.clientWidth;
|
||||
setEaseOutTransition();
|
||||
setPosition(newPosition, true);
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
if (!scrubberSliderEl.current) {
|
||||
return;
|
||||
}
|
||||
const newPosition = getPosition() - scrubberSliderEl.current.clientWidth;
|
||||
setPosition(newPosition);
|
||||
const slider = sliderEl.current!;
|
||||
const newPosition = position.current - slider.clientWidth;
|
||||
setEaseOutTransition();
|
||||
setPosition(newPosition, true);
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
function getTagStyle(i: number): CSSProperties {
|
||||
if (
|
||||
!scrubberSliderEl.current ||
|
||||
spriteItems.length === 0 ||
|
||||
getBounds() === 0
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
if (!spriteItems) return;
|
||||
|
||||
const tags = window.document.getElementsByClassName("scrubber-tag");
|
||||
if (tags.length === 0) {
|
||||
return {};
|
||||
}
|
||||
return scene.scene_markers.map((marker, index) => {
|
||||
const { duration } = file;
|
||||
const left = (scrubWidth * marker.seconds) / duration;
|
||||
const style = { left: `${left}px` };
|
||||
|
||||
let tag: Element | null;
|
||||
for (let index = 0; index < tags.length; index++) {
|
||||
tag = tags.item(index);
|
||||
const id = tag?.getAttribute("data-marker-id") ?? null;
|
||||
if (id === i.toString()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const marker = props.scene.scene_markers[i];
|
||||
const duration = Number(props.file.duration);
|
||||
const percentage = marker.seconds / duration;
|
||||
|
||||
const left =
|
||||
scrubberSliderEl.current.scrollWidth * percentage -
|
||||
tag!.clientWidth / 2;
|
||||
return {
|
||||
left: `${left}px`,
|
||||
height: 20,
|
||||
};
|
||||
}
|
||||
|
||||
return props.scene.scene_markers.map((marker, index) => {
|
||||
const dataAttrs = {
|
||||
"data-marker-id": index,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="scrubber-tag"
|
||||
style={getTagStyle(index)}
|
||||
{...dataAttrs}
|
||||
style={style}
|
||||
data-marker-id={index}
|
||||
>
|
||||
{marker.title || marker.primary_tag.name}
|
||||
</div>
|
||||
@@ -318,38 +319,17 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
|
||||
}
|
||||
|
||||
function renderSprites() {
|
||||
function getStyleForSprite(index: number): CSSProperties {
|
||||
if (!props.scene.paths.vtt) {
|
||||
return {};
|
||||
}
|
||||
const sprite = spriteItems[index];
|
||||
const left = sprite.w * index;
|
||||
const path = props.scene.paths.vtt.replace("_thumbs.vtt", "_sprite.jpg"); // TODO: Gnarly
|
||||
return {
|
||||
width: `${sprite.w}px`,
|
||||
height: `${sprite.h}px`,
|
||||
margin: "0px auto",
|
||||
backgroundPosition: `${-sprite.x}px ${-sprite.y}px`,
|
||||
backgroundImage: `url(${path})`,
|
||||
left: `${left}px`,
|
||||
};
|
||||
}
|
||||
if (!scene.paths.vtt) return;
|
||||
|
||||
return spriteItems.map((spriteItem, index) => {
|
||||
const dataAttrs = {
|
||||
"data-sprite-item-id": index,
|
||||
};
|
||||
return spriteItems?.map((sprite, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="scrubber-item"
|
||||
style={getStyleForSprite(index)}
|
||||
{...dataAttrs}
|
||||
style={sprite.style}
|
||||
data-sprite-item-id={index}
|
||||
>
|
||||
<span className="scrubber-item-time">
|
||||
{TextUtils.secondsToTimestamp(spriteItem.start)} -{" "}
|
||||
{TextUtils.secondsToTimestamp(spriteItem.end)}
|
||||
</span>
|
||||
<span className="scrubber-item-time">{sprite.time}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -367,10 +347,10 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
|
||||
</Button>
|
||||
<div ref={contentEl} className="scrubber-content">
|
||||
<div className="scrubber-tags-background" />
|
||||
<div ref={positionIndicatorEl} id="scrubber-position-indicator" />
|
||||
<div ref={indicatorEl} id="scrubber-position-indicator" />
|
||||
<div id="scrubber-current-position" />
|
||||
<div className="scrubber-viewport">
|
||||
<div ref={scrubberSliderEl} className="scrubber-slider">
|
||||
<div ref={sliderEl} className="scrubber-slider">
|
||||
<div className="scrubber-tags">{renderTags()}</div>
|
||||
{renderSprites()}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import videojs, { VideoJsPlayer } from "video.js";
|
||||
|
||||
const BigPlayButton = videojs.getComponent("BigPlayButton");
|
||||
// prettier-ignore
|
||||
const BigPlayButton = videojs.getComponent("BigPlayButton") as unknown as typeof videojs.BigPlayButton;
|
||||
|
||||
class BigPlayPauseButton extends BigPlayButton {
|
||||
handleClick(event: videojs.EventTarget.Event) {
|
||||
if (this.player().paused()) {
|
||||
// @ts-ignore for some reason handleClick isn't defined in BigPlayButton type. Not sure why
|
||||
super.handleClick(event);
|
||||
} else {
|
||||
this.player().pause();
|
||||
@@ -18,9 +18,8 @@ class BigPlayPauseButton extends BigPlayButton {
|
||||
}
|
||||
|
||||
class BigButtonGroup extends videojs.getComponent("Component") {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
constructor(player: VideoJsPlayer, options: any) {
|
||||
super(player, options);
|
||||
constructor(player: VideoJsPlayer) {
|
||||
super(player);
|
||||
|
||||
this.addChild("seekButton", {
|
||||
direction: "back",
|
||||
@@ -42,13 +41,29 @@ class BigButtonGroup extends videojs.getComponent("Component") {
|
||||
}
|
||||
}
|
||||
|
||||
const bigButtons = function (this: VideoJsPlayer) {
|
||||
this.addChild("BigButtonGroup");
|
||||
};
|
||||
class BigButtonsPlugin extends videojs.getPlugin("plugin") {
|
||||
constructor(player: VideoJsPlayer) {
|
||||
super(player);
|
||||
|
||||
player.ready(() => {
|
||||
player.addChild("BigButtonGroup");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin with video.js.
|
||||
videojs.registerComponent("BigButtonGroup", BigButtonGroup);
|
||||
videojs.registerComponent("BigPlayPauseButton", BigPlayPauseButton);
|
||||
videojs.registerPlugin("bigButtons", bigButtons);
|
||||
videojs.registerPlugin("bigButtons", BigButtonsPlugin);
|
||||
|
||||
export default bigButtons;
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
declare module "video.js" {
|
||||
interface VideoJsPlayer {
|
||||
bigButtons: () => BigButtonsPlugin;
|
||||
}
|
||||
interface VideoJsPlayerPluginOptions {
|
||||
bigButtons?: {};
|
||||
}
|
||||
}
|
||||
|
||||
export default BigButtonsPlugin;
|
||||
|
||||
@@ -1,83 +1,180 @@
|
||||
import videojs, { VideoJsPlayer } from "video.js";
|
||||
|
||||
const offset = function (this: VideoJsPlayer) {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const Player = this.constructor as any;
|
||||
|
||||
if (!Player.__super__ || !Player.__super__.__offsetInit) {
|
||||
Player.__super__ = {
|
||||
__offsetInit: true,
|
||||
duration: Player.prototype.duration,
|
||||
currentTime: Player.prototype.currentTime,
|
||||
remainingTime: Player.prototype.remainingTime,
|
||||
getCache: Player.prototype.getCache,
|
||||
};
|
||||
|
||||
Player.prototype.clearOffsetDuration = function () {
|
||||
this._offsetDuration = undefined;
|
||||
this._offsetStart = undefined;
|
||||
};
|
||||
|
||||
Player.prototype.setOffsetDuration = function (duration: number) {
|
||||
this._offsetDuration = duration;
|
||||
};
|
||||
|
||||
Player.prototype.duration = function () {
|
||||
if (this._offsetDuration !== undefined) {
|
||||
return this._offsetDuration;
|
||||
export interface ISource extends videojs.Tech.SourceObject {
|
||||
offset?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
return Player.__super__.duration.apply(this, arguments);
|
||||
};
|
||||
|
||||
Player.prototype.currentTime = function (seconds: number) {
|
||||
if (seconds !== undefined && this._offsetDuration !== undefined) {
|
||||
this._offsetStart = seconds;
|
||||
interface ICue extends TextTrackCue {
|
||||
_startTime?: number;
|
||||
_endTime?: number;
|
||||
}
|
||||
|
||||
function offsetMiddleware(player: VideoJsPlayer) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- allow access to private tech methods
|
||||
let tech: any;
|
||||
let source: ISource;
|
||||
let offsetStart: number | undefined;
|
||||
let seeking = 0;
|
||||
|
||||
function initCues(cues: TextTrackCueList) {
|
||||
const offset = offsetStart ?? 0;
|
||||
for (let j = 0; j < cues.length; j++) {
|
||||
const cue = cues[j] as ICue;
|
||||
cue._startTime = cue.startTime;
|
||||
cue.startTime = cue._startTime - offset;
|
||||
cue._endTime = cue.endTime;
|
||||
cue.endTime = cue._endTime - offset;
|
||||
}
|
||||
}
|
||||
|
||||
function updateOffsetStart(offset: number | undefined) {
|
||||
offsetStart = offset;
|
||||
|
||||
if (!tech) return;
|
||||
offset = offset ?? 0;
|
||||
|
||||
const tracks = tech.remoteTextTracks();
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const { cues } = tracks[i];
|
||||
if (cues) {
|
||||
for (let j = 0; j < cues.length; j++) {
|
||||
const cue = cues[j] as ICue;
|
||||
if (cue._startTime === undefined || cue._endTime === undefined) {
|
||||
continue;
|
||||
}
|
||||
cue.startTime = cue._startTime - offset;
|
||||
cue.endTime = cue._endTime - offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const srcUrl = new URL(this.src());
|
||||
srcUrl.searchParams.delete("start");
|
||||
srcUrl.searchParams.append("start", seconds.toString());
|
||||
const currentSrc = this.currentSource();
|
||||
const newSources = this.currentSources().map(
|
||||
(source: videojs.Tech.SourceObject) => {
|
||||
return {
|
||||
...source,
|
||||
src:
|
||||
source.src === currentSrc.src ? srcUrl.toString() : source.src,
|
||||
};
|
||||
}
|
||||
);
|
||||
this.src(newSources);
|
||||
this.play();
|
||||
setTech(newTech: videojs.Tech) {
|
||||
tech = newTech;
|
||||
|
||||
const _addRemoteTextTrack = tech.addRemoteTextTrack.bind(tech);
|
||||
function addRemoteTextTrack(
|
||||
this: VideoJsPlayer,
|
||||
options: videojs.TextTrackOptions,
|
||||
manualCleanup: boolean
|
||||
) {
|
||||
const textTrack = _addRemoteTextTrack(options, manualCleanup);
|
||||
textTrack.addEventListener("load", () => {
|
||||
const { cues } = textTrack.track;
|
||||
if (cues) {
|
||||
initCues(cues);
|
||||
}
|
||||
});
|
||||
|
||||
return textTrack;
|
||||
}
|
||||
tech.addRemoteTextTrack = addRemoteTextTrack;
|
||||
|
||||
const trackEls: HTMLTrackElement[] = tech.remoteTextTrackEls();
|
||||
for (let i = 0; i < trackEls.length; i++) {
|
||||
const trackEl = trackEls[i];
|
||||
const { track } = trackEl;
|
||||
if (track.cues) {
|
||||
initCues(track.cues);
|
||||
} else {
|
||||
trackEl.addEventListener("load", () => {
|
||||
if (track.cues) {
|
||||
initCues(track.cues);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
setSource(
|
||||
srcObj: ISource,
|
||||
next: (err: unknown, src: videojs.Tech.SourceObject) => void
|
||||
) {
|
||||
if (srcObj.offset && srcObj.duration) {
|
||||
updateOffsetStart(0);
|
||||
} else {
|
||||
updateOffsetStart(undefined);
|
||||
}
|
||||
source = srcObj;
|
||||
next(null, srcObj);
|
||||
},
|
||||
duration(seconds: number) {
|
||||
if (source.duration) {
|
||||
return source.duration;
|
||||
} else {
|
||||
return seconds;
|
||||
}
|
||||
return (
|
||||
(this._offsetStart ?? 0) +
|
||||
Player.__super__.currentTime.apply(this, arguments)
|
||||
);
|
||||
};
|
||||
|
||||
Player.prototype.getCache = function () {
|
||||
const cache = Player.__super__.getCache.apply(this);
|
||||
if (this._offsetDuration !== undefined)
|
||||
return {
|
||||
...cache,
|
||||
currentTime:
|
||||
(this._offsetStart ?? 0) + Player.__super__.currentTime.apply(this),
|
||||
};
|
||||
return cache;
|
||||
};
|
||||
|
||||
Player.prototype.remainingTime = function () {
|
||||
if (this._offsetDuration !== undefined) {
|
||||
return this._offsetDuration - this.currentTime();
|
||||
},
|
||||
buffered(buffers: TimeRanges) {
|
||||
if (offsetStart === undefined) {
|
||||
return buffers;
|
||||
}
|
||||
return this.duration() - this.currentTime();
|
||||
|
||||
const timeRanges: number[][] = [];
|
||||
for (let i = 0; i < buffers.length; i++) {
|
||||
const start = buffers.start(i) + offsetStart;
|
||||
const end = buffers.end(i) + offsetStart;
|
||||
|
||||
timeRanges.push([start, end]);
|
||||
}
|
||||
|
||||
// types for createTimeRanges are incorrect, should be number[][] not TimeRange[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return videojs.createTimeRanges(timeRanges as any);
|
||||
},
|
||||
currentTime(seconds: number) {
|
||||
return (offsetStart ?? 0) + seconds;
|
||||
},
|
||||
setCurrentTime(seconds: number) {
|
||||
if (offsetStart === undefined) {
|
||||
return seconds;
|
||||
}
|
||||
|
||||
const offsetSeconds = seconds - offsetStart;
|
||||
const buffers = tech.buffered() as TimeRanges;
|
||||
for (let i = 0; i < buffers.length; i++) {
|
||||
const start = buffers.start(i);
|
||||
const end = buffers.end(i);
|
||||
// seek point is in buffer, just seek normally
|
||||
if (start <= offsetSeconds && offsetSeconds <= end) {
|
||||
return offsetSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
updateOffsetStart(seconds);
|
||||
|
||||
const srcUrl = new URL(source.src);
|
||||
srcUrl.searchParams.set("start", seconds.toString());
|
||||
source.src = srcUrl.toString();
|
||||
|
||||
const poster = player.poster();
|
||||
const playbackRate = tech.playbackRate();
|
||||
seeking = tech.paused() ? 1 : 2;
|
||||
player.poster("");
|
||||
tech.setSource(source);
|
||||
tech.setPlaybackRate(playbackRate);
|
||||
tech.one("canplay", () => {
|
||||
player.poster(poster);
|
||||
if (seeking === 1) {
|
||||
tech.pause();
|
||||
}
|
||||
seeking = 0;
|
||||
});
|
||||
tech.trigger("timeupdate");
|
||||
tech.trigger("pause");
|
||||
tech.trigger("seeking");
|
||||
tech.play();
|
||||
|
||||
return 0;
|
||||
},
|
||||
callPlay() {
|
||||
if (seeking) {
|
||||
seeking = 2;
|
||||
return videojs.middleware.TERMINATOR;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Register the plugin with video.js.
|
||||
videojs.registerPlugin("offset", offset);
|
||||
|
||||
export default offset;
|
||||
videojs.use("*", offsetMiddleware);
|
||||
|
||||
@@ -1,107 +1,142 @@
|
||||
import videojs, { VideoJsPlayer } from "video.js";
|
||||
|
||||
const markers = function (this: VideoJsPlayer) {
|
||||
const player = this;
|
||||
|
||||
function getPosition(marker: VTTCue) {
|
||||
return (marker.startTime / player.duration()) * 100;
|
||||
interface IMarker {
|
||||
title: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
function createMarkerToolTip() {
|
||||
interface IMarkersOptions {
|
||||
markers?: IMarker[];
|
||||
}
|
||||
|
||||
class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
private markers: IMarker[] = [];
|
||||
private markerDivs: HTMLDivElement[] = [];
|
||||
private markerTooltip: HTMLElement | null = null;
|
||||
private defaultTooltip: HTMLElement | null = null;
|
||||
|
||||
constructor(player: VideoJsPlayer, options?: IMarkersOptions) {
|
||||
super(player);
|
||||
|
||||
player.ready(() => {
|
||||
// create marker tooltip
|
||||
const tooltip = videojs.dom.createEl("div") as HTMLElement;
|
||||
tooltip.className = "vjs-marker-tooltip";
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
function removeMarkerToolTip() {
|
||||
const div = player
|
||||
.el()
|
||||
.querySelector(".vjs-progress-holder .vjs-marker-tooltip");
|
||||
if (div) div.remove();
|
||||
}
|
||||
|
||||
function createMarkerDiv(marker: VTTCue) {
|
||||
const markerDiv = videojs.dom.createEl(
|
||||
"div",
|
||||
{},
|
||||
{
|
||||
"data-marker-time": marker.startTime,
|
||||
}
|
||||
) as HTMLElement;
|
||||
|
||||
markerDiv.className = "vjs-marker";
|
||||
markerDiv.style.left = getPosition(marker) + "%";
|
||||
|
||||
// bind click event to seek to marker time
|
||||
markerDiv.addEventListener("click", function () {
|
||||
const time = this.getAttribute("data-marker-time");
|
||||
player.currentTime(Number(time));
|
||||
});
|
||||
|
||||
// show tooltip on hover
|
||||
markerDiv.addEventListener("mouseenter", function () {
|
||||
// create and show tooltip
|
||||
const tooltip = createMarkerToolTip();
|
||||
tooltip.innerText = marker.text;
|
||||
tooltip.style.visibility = "hidden";
|
||||
|
||||
const parent = player
|
||||
.el()
|
||||
.querySelector(".vjs-progress-holder .vjs-mouse-display");
|
||||
if (parent) parent.appendChild(tooltip);
|
||||
this.markerTooltip = tooltip;
|
||||
|
||||
parent?.appendChild(tooltip);
|
||||
// save default tooltip
|
||||
this.defaultTooltip = player
|
||||
.el()
|
||||
.querySelector<HTMLElement>(
|
||||
".vjs-progress-holder .vjs-mouse-display .vjs-time-tooltip"
|
||||
);
|
||||
|
||||
options?.markers?.forEach(this.addMarker, this);
|
||||
});
|
||||
|
||||
player.on("loadedmetadata", () => {
|
||||
const seekBar = player.el().querySelector(".vjs-progress-holder");
|
||||
const duration = this.player.duration();
|
||||
|
||||
for (let i = 0; i < this.markers.length; i++) {
|
||||
const marker = this.markers[i];
|
||||
const markerDiv = this.markerDivs[i];
|
||||
|
||||
markerDiv.style.left = `${(marker.time / duration) * 100}%`;
|
||||
if (seekBar) seekBar.appendChild(markerDiv);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showMarkerTooltip(title: string) {
|
||||
if (!this.markerTooltip) return;
|
||||
|
||||
this.markerTooltip.innerText = title;
|
||||
this.markerTooltip.style.visibility = "visible";
|
||||
|
||||
// hide default tooltip
|
||||
const defaultTooltip = parent?.querySelector(
|
||||
".vjs-time-tooltip"
|
||||
) as HTMLElement;
|
||||
defaultTooltip.style.visibility = "hidden";
|
||||
});
|
||||
if (this.defaultTooltip) this.defaultTooltip.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
markerDiv.addEventListener("mouseout", function () {
|
||||
removeMarkerToolTip();
|
||||
private hideMarkerTooltip() {
|
||||
if (this.markerTooltip) this.markerTooltip.style.visibility = "hidden";
|
||||
|
||||
// show default tooltip
|
||||
const defaultTooltip = player
|
||||
.el()
|
||||
.querySelector(
|
||||
".vjs-progress-holder .vjs-mouse-display .vjs-time-tooltip"
|
||||
) as HTMLElement;
|
||||
if (defaultTooltip) defaultTooltip.style.visibility = "visible";
|
||||
});
|
||||
|
||||
return markerDiv;
|
||||
if (this.defaultTooltip) this.defaultTooltip.style.visibility = "visible";
|
||||
}
|
||||
|
||||
function removeMarkerDivs() {
|
||||
const divs = player
|
||||
.el()
|
||||
.querySelectorAll(".vjs-progress-holder .vjs-marker");
|
||||
divs.forEach((div) => {
|
||||
addMarker(marker: IMarker) {
|
||||
const markerDiv = videojs.dom.createEl("div") as HTMLDivElement;
|
||||
markerDiv.className = "vjs-marker";
|
||||
|
||||
const duration = this.player.duration();
|
||||
markerDiv.style.position = `${(marker.time / duration) * 100}%`;
|
||||
|
||||
// bind click event to seek to marker time
|
||||
markerDiv.addEventListener("click", () =>
|
||||
this.player.currentTime(marker.time)
|
||||
);
|
||||
|
||||
// show/hide tooltip on hover
|
||||
markerDiv.addEventListener("mouseenter", () => {
|
||||
this.showMarkerTooltip(marker.title);
|
||||
markerDiv.toggleAttribute("marker-tooltip-shown", true);
|
||||
});
|
||||
markerDiv.addEventListener("mouseout", () => {
|
||||
this.hideMarkerTooltip();
|
||||
markerDiv.toggleAttribute("marker-tooltip-shown", false);
|
||||
});
|
||||
|
||||
const seekBar = this.player.el().querySelector(".vjs-progress-holder");
|
||||
if (seekBar) seekBar.appendChild(markerDiv);
|
||||
|
||||
this.markers.push(marker);
|
||||
this.markerDivs.push(markerDiv);
|
||||
}
|
||||
|
||||
addMarkers(markers: IMarker[]) {
|
||||
markers.forEach(this.addMarker, this);
|
||||
}
|
||||
|
||||
removeMarker(marker: IMarker) {
|
||||
const i = this.markers.indexOf(marker);
|
||||
if (i === -1) return;
|
||||
|
||||
this.markers.splice(i, 1);
|
||||
|
||||
const div = this.markerDivs.splice(i, 1)[0];
|
||||
if (div.hasAttribute("marker-tooltip-shown")) {
|
||||
this.hideMarkerTooltip();
|
||||
}
|
||||
div.remove();
|
||||
});
|
||||
}
|
||||
|
||||
this.on("loadedmetadata", function () {
|
||||
removeMarkerDivs();
|
||||
removeMarkerToolTip();
|
||||
removeMarkers(markers: IMarker[]) {
|
||||
markers.forEach(this.removeMarker, this);
|
||||
}
|
||||
|
||||
const textTracks = player.remoteTextTracks();
|
||||
const seekBar = player.el().querySelector(".vjs-progress-holder");
|
||||
|
||||
if (seekBar && textTracks.length > 0) {
|
||||
const vttTrack = textTracks[0];
|
||||
if (!vttTrack || !vttTrack.cues) return;
|
||||
for (let i = 0; i < vttTrack.cues.length; i++) {
|
||||
const cue = vttTrack.cues[i];
|
||||
const markerDiv = createMarkerDiv(cue as VTTCue);
|
||||
seekBar.appendChild(markerDiv);
|
||||
clearMarkers() {
|
||||
this.removeMarkers(this.markers);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Register the plugin with video.js.
|
||||
videojs.registerPlugin("markers", markers);
|
||||
videojs.registerPlugin("markers", MarkersPlugin);
|
||||
|
||||
export default markers;
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
declare module "video.js" {
|
||||
interface VideoJsPlayer {
|
||||
markers: () => MarkersPlugin;
|
||||
}
|
||||
interface VideoJsPlayerPluginOptions {
|
||||
markers?: IMarkersOptions;
|
||||
}
|
||||
}
|
||||
|
||||
export default MarkersPlugin;
|
||||
|
||||
@@ -1,30 +1,59 @@
|
||||
import videojs, { VideoJsPlayer } from "video.js";
|
||||
import localForage from "localforage";
|
||||
|
||||
const persistVolume = function (this: VideoJsPlayer) {
|
||||
const player = this;
|
||||
const levelKey = "volume-level";
|
||||
const mutedKey = "volume-muted";
|
||||
|
||||
player.on("volumechange", function () {
|
||||
interface IPersistVolumeOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
class PersistVolumePlugin extends videojs.getPlugin("plugin") {
|
||||
enabled: boolean;
|
||||
|
||||
constructor(player: VideoJsPlayer, options?: IPersistVolumeOptions) {
|
||||
super(player, options);
|
||||
|
||||
this.enabled = options?.enabled ?? true;
|
||||
|
||||
player.on("volumechange", () => {
|
||||
if (this.enabled) {
|
||||
localForage.setItem(levelKey, player.volume());
|
||||
localForage.setItem(mutedKey, player.muted());
|
||||
});
|
||||
|
||||
localForage.getItem(levelKey).then((value) => {
|
||||
if (value !== null) {
|
||||
player.volume(value as number);
|
||||
}
|
||||
});
|
||||
|
||||
localForage.getItem(mutedKey).then((value) => {
|
||||
player.ready(() => {
|
||||
this.ready();
|
||||
});
|
||||
}
|
||||
|
||||
private ready() {
|
||||
localForage.getItem<number>(levelKey).then((value) => {
|
||||
if (value !== null) {
|
||||
player.muted(value as boolean);
|
||||
this.player.volume(value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
localForage.getItem<boolean>(mutedKey).then((value) => {
|
||||
if (value !== null) {
|
||||
this.player.muted(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin with video.js.
|
||||
videojs.registerPlugin("persistVolume", persistVolume);
|
||||
videojs.registerPlugin("persistVolume", PersistVolumePlugin);
|
||||
|
||||
export default persistVolume;
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
declare module "video.js" {
|
||||
interface VideoJsPlayer {
|
||||
persistVolume: () => PersistVolumePlugin;
|
||||
}
|
||||
interface VideoJsPlayerPluginOptions {
|
||||
persistVolume?: IPersistVolumeOptions;
|
||||
}
|
||||
}
|
||||
|
||||
export default PersistVolumePlugin;
|
||||
|
||||
@@ -1,42 +1,70 @@
|
||||
import videojs, { VideoJsPlayer } from "video.js";
|
||||
|
||||
interface ISource extends videojs.Tech.SourceObject {
|
||||
export interface ISource extends videojs.Tech.SourceObject {
|
||||
label?: string;
|
||||
selected?: boolean;
|
||||
sortIndex?: number;
|
||||
}
|
||||
|
||||
const MenuButton = videojs.getComponent("MenuButton");
|
||||
const MenuItem = videojs.getComponent("MenuItem");
|
||||
|
||||
class SourceMenuItem extends MenuItem {
|
||||
private parent: SourceMenuButton;
|
||||
class SourceMenuItem extends videojs.getComponent("MenuItem") {
|
||||
public source: ISource;
|
||||
public index: number;
|
||||
public isSelected = false;
|
||||
|
||||
constructor(
|
||||
parent: SourceMenuButton,
|
||||
source: ISource,
|
||||
index: number,
|
||||
player: VideoJsPlayer,
|
||||
options: videojs.MenuItemOptions
|
||||
) {
|
||||
constructor(parent: SourceMenuButton, source: ISource) {
|
||||
const options = {} as videojs.MenuItemOptions;
|
||||
options.selectable = true;
|
||||
options.multiSelectable = false;
|
||||
options.label = source.label || source.type;
|
||||
|
||||
super(player, options);
|
||||
super(parent.player(), options);
|
||||
|
||||
this.parent = parent;
|
||||
this.source = source;
|
||||
this.index = index;
|
||||
|
||||
this.addClass("vjs-source-menu-item");
|
||||
}
|
||||
|
||||
selected(selected: boolean): void {
|
||||
super.selected(selected);
|
||||
this.isSelected = selected;
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.parent.trigger("selected", this);
|
||||
if (this.isSelected) return;
|
||||
|
||||
this.trigger("selected");
|
||||
}
|
||||
}
|
||||
|
||||
class SourceMenuButton extends MenuButton {
|
||||
class SourceMenuButton extends videojs.getComponent("MenuButton") {
|
||||
private items: SourceMenuItem[] = [];
|
||||
private selectedSource: ISource | null = null;
|
||||
|
||||
constructor(player: VideoJsPlayer) {
|
||||
super(player);
|
||||
|
||||
player.on("loadstart", () => {
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
|
||||
public setSources(sources: ISource[]) {
|
||||
this.selectedSource = null;
|
||||
|
||||
this.items = sources.map((source, i) => {
|
||||
if (i === 0) {
|
||||
this.selectedSource = source;
|
||||
}
|
||||
|
||||
const item = new SourceMenuItem(this, source);
|
||||
|
||||
item.on("selected", () => {
|
||||
this.selectedSource = source;
|
||||
|
||||
this.trigger("sourceselected", source);
|
||||
});
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
createEl() {
|
||||
return videojs.dom.createEl("div", {
|
||||
className:
|
||||
@@ -45,106 +73,154 @@ class SourceMenuButton extends MenuButton {
|
||||
}
|
||||
|
||||
createItems() {
|
||||
const player = this.player();
|
||||
const menuButton = this;
|
||||
if (this.items === undefined) return [];
|
||||
|
||||
// slice so that we don't alter the order of the original array
|
||||
const sources = player.currentSources().slice() as ISource[];
|
||||
|
||||
sources.sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0));
|
||||
|
||||
const hasSelected = sources.some((source) => source.selected);
|
||||
if (!hasSelected && sources.length > 0) {
|
||||
sources[0].selected = true;
|
||||
for (const item of this.items) {
|
||||
item.selected(item.source === this.selectedSource);
|
||||
}
|
||||
|
||||
menuButton.on("selected", function (e, selectedItem) {
|
||||
// don't do anything if re-selecting the same source
|
||||
if (selectedItem.source.selected) {
|
||||
return;
|
||||
return this.items;
|
||||
}
|
||||
}
|
||||
|
||||
// populate source sortIndex first if not present
|
||||
const currentSources = (player.currentSources() as ISource[]).map(
|
||||
(src, i) => {
|
||||
return {
|
||||
...src,
|
||||
sortIndex: src.sortIndex ?? i,
|
||||
selected: false,
|
||||
};
|
||||
}
|
||||
);
|
||||
class SourceSelectorPlugin extends videojs.getPlugin("plugin") {
|
||||
private menu: SourceMenuButton;
|
||||
private sources: ISource[] = [];
|
||||
private selectedIndex = -1;
|
||||
private cleanupTextTracks: HTMLTrackElement[] = [];
|
||||
private manualTextTracks: HTMLTrackElement[] = [];
|
||||
|
||||
// put the selected source at the top of the list
|
||||
const selectedIndex = currentSources.findIndex(
|
||||
(src) => src.sortIndex === selectedItem.index
|
||||
);
|
||||
const selectedSrc = currentSources.splice(selectedIndex, 1)[0];
|
||||
selectedSrc.selected = true;
|
||||
currentSources.unshift(selectedSrc);
|
||||
constructor(player: VideoJsPlayer) {
|
||||
super(player);
|
||||
|
||||
this.menu = new SourceMenuButton(player);
|
||||
|
||||
this.menu.on("sourceselected", (_, source: ISource) => {
|
||||
this.selectedIndex = this.sources.findIndex((src) => src === source);
|
||||
if (this.selectedIndex === -1) return;
|
||||
|
||||
const currentTime = player.currentTime();
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
(player as any).clearOffsetDuration();
|
||||
player.src(currentSources);
|
||||
// put the selected source at the top of the list
|
||||
const loadSources = [...this.sources];
|
||||
const selectedSrc = loadSources.splice(this.selectedIndex, 1)[0];
|
||||
loadSources.unshift(selectedSrc);
|
||||
|
||||
const paused = player.paused();
|
||||
player.src(loadSources);
|
||||
player.one("canplay", () => {
|
||||
if (paused) {
|
||||
player.pause();
|
||||
}
|
||||
player.currentTime(currentTime);
|
||||
});
|
||||
player.play();
|
||||
});
|
||||
|
||||
return sources.map((source, index) => {
|
||||
const label = source.label || source.type;
|
||||
const item = new SourceMenuItem(
|
||||
menuButton,
|
||||
source,
|
||||
index,
|
||||
this.player(),
|
||||
{
|
||||
label: label,
|
||||
selected: source.selected || (!hasSelected && index === 0),
|
||||
}
|
||||
);
|
||||
|
||||
menuButton.on("selected", function (selectedItem) {
|
||||
if (selectedItem !== item) {
|
||||
item.selected(false);
|
||||
}
|
||||
});
|
||||
|
||||
item.addClass("vjs-source-menu-item");
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceSelector = function (this: VideoJsPlayer) {
|
||||
const player = this;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const PlayerConstructor = this.constructor as any;
|
||||
if (!PlayerConstructor.__sourceSelector) {
|
||||
PlayerConstructor.__sourceSelector = {
|
||||
selectSource: PlayerConstructor.prototype.selectSource,
|
||||
};
|
||||
}
|
||||
|
||||
videojs.registerComponent("SourceMenuButton", SourceMenuButton);
|
||||
|
||||
player.on("loadedmetadata", function () {
|
||||
player.on("ready", () => {
|
||||
const { controlBar } = player;
|
||||
const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el();
|
||||
|
||||
const existingMenuButton = controlBar.getChild("SourceMenuButton");
|
||||
if (existingMenuButton) controlBar.removeChild(existingMenuButton);
|
||||
|
||||
const menuButton = controlBar.addChild("SourceMenuButton");
|
||||
|
||||
controlBar.el().insertBefore(menuButton.el(), fullscreenToggle);
|
||||
controlBar.addChild(this.menu);
|
||||
controlBar.el().insertBefore(this.menu.el(), fullscreenToggle);
|
||||
});
|
||||
};
|
||||
|
||||
player.on("loadedmetadata", () => {
|
||||
if (!player.videoWidth() && !player.videoHeight()) {
|
||||
// Occurs during preload when videos with supported audio/unsupported video are preloaded.
|
||||
// Treat this as a decoding error and try the next source without playing.
|
||||
// However on Safari we get an media event when m3u8 is loaded which needs to be ignored.
|
||||
if (player.error() !== null) return;
|
||||
const currentSrc = player.currentSrc();
|
||||
if (currentSrc !== null && !currentSrc.includes(".m3u8")) {
|
||||
player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
player.on("error", () => {
|
||||
const error = player.error();
|
||||
if (!error) return;
|
||||
|
||||
// Only try next source if media was unsupported
|
||||
if (error.code !== MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) return;
|
||||
|
||||
const currentSource = player.currentSource() as ISource;
|
||||
console.log(`Source '${currentSource.label}' is unsupported`);
|
||||
|
||||
if (this.sources.length > 1) {
|
||||
if (this.selectedIndex === -1) return;
|
||||
|
||||
this.sources.splice(this.selectedIndex, 1);
|
||||
const newSource = this.sources[0];
|
||||
console.log(`Trying next source in playlist: '${newSource.label}'`);
|
||||
this.menu.setSources(this.sources);
|
||||
this.selectedIndex = 0;
|
||||
player.src(this.sources);
|
||||
player.load();
|
||||
player.play();
|
||||
} else {
|
||||
console.log("No more sources in playlist");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSources(sources: ISource[]) {
|
||||
const cleanupTracks = this.cleanupTextTracks.splice(0);
|
||||
for (const track of cleanupTracks) {
|
||||
this.player.removeRemoteTextTrack(track);
|
||||
}
|
||||
|
||||
this.menu.setSources(sources);
|
||||
if (sources.length !== 0) {
|
||||
this.selectedIndex = 0;
|
||||
} else {
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
this.sources = sources;
|
||||
this.player.src(this.sources);
|
||||
}
|
||||
|
||||
get textTracks(): HTMLTrackElement[] {
|
||||
return [...this.cleanupTextTracks, ...this.manualTextTracks];
|
||||
}
|
||||
|
||||
addTextTrack(options: videojs.TextTrackOptions, manualCleanup: boolean) {
|
||||
const track = this.player.addRemoteTextTrack(options, true);
|
||||
if (manualCleanup) {
|
||||
this.manualTextTracks.push(track);
|
||||
} else {
|
||||
this.cleanupTextTracks.push(track);
|
||||
}
|
||||
return track;
|
||||
}
|
||||
|
||||
removeTextTrack(track: HTMLTrackElement) {
|
||||
this.player.removeRemoteTextTrack(track);
|
||||
let index = this.manualTextTracks.indexOf(track);
|
||||
if (index != -1) {
|
||||
this.manualTextTracks.splice(index, 1);
|
||||
}
|
||||
index = this.cleanupTextTracks.indexOf(track);
|
||||
if (index != -1) {
|
||||
this.cleanupTextTracks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin with video.js.
|
||||
videojs.registerPlugin("sourceSelector", sourceSelector);
|
||||
videojs.registerComponent("SourceMenuButton", SourceMenuButton);
|
||||
videojs.registerPlugin("sourceSelector", SourceSelectorPlugin);
|
||||
|
||||
export default sourceSelector;
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
declare module "video.js" {
|
||||
interface VideoJsPlayer {
|
||||
sourceSelector: () => SourceSelectorPlugin;
|
||||
}
|
||||
interface VideoJsPlayerPluginOptions {
|
||||
sourceSelector?: {};
|
||||
}
|
||||
}
|
||||
|
||||
export default SourceSelectorPlugin;
|
||||
|
||||
@@ -12,6 +12,11 @@ $sceneTabWidth: 450px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
&.portrait .video-js {
|
||||
height: 177.78vw;
|
||||
}
|
||||
}
|
||||
|
||||
.video-js {
|
||||
height: 56.25vw;
|
||||
overflow: hidden;
|
||||
@@ -20,22 +25,11 @@ $sceneTabWidth: 450px;
|
||||
@media (min-width: 1200px) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.portrait .video-js {
|
||||
height: 177.78vw;
|
||||
}
|
||||
|
||||
.vjs-button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vjs-vtt-thumbnail-display {
|
||||
// default opacity to 0, it gets set to 1 when moused-over.
|
||||
// prevents the border from showing up when initially loaded
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.vjs-big-button-group {
|
||||
display: none;
|
||||
height: 80px;
|
||||
@@ -44,6 +38,7 @@ $sceneTabWidth: 450px;
|
||||
position: absolute;
|
||||
top: calc(50% - 40px);
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
|
||||
.vjs-button {
|
||||
font-size: 4em;
|
||||
@@ -57,8 +52,176 @@ $sceneTabWidth: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-control-bar {
|
||||
background: none;
|
||||
|
||||
/* Scales control size */
|
||||
font-size: 15px;
|
||||
|
||||
&::before {
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.4) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
bottom: 0;
|
||||
content: "";
|
||||
height: 10rem;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-time-control {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 0 4px;
|
||||
pointer-events: none;
|
||||
|
||||
.vjs-control-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-duration {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.vjs-remaining-time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vjs-progress-control {
|
||||
bottom: 3rem;
|
||||
margin-left: 1%;
|
||||
position: absolute;
|
||||
width: 98%;
|
||||
|
||||
.vjs-play-progress .vjs-time-tooltip,
|
||||
&:hover .vjs-play-progress .vjs-time-tooltip {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-volume-control {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* stylelint-disable declaration-no-important */
|
||||
.vjs-slider {
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
/* stylelint-enable declaration-no-important */
|
||||
|
||||
.vjs-vtt-thumbnail-display {
|
||||
border: 2px solid white;
|
||||
border-radius: 2px;
|
||||
bottom: 90px;
|
||||
box-shadow: 0 0 7px rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.vjs-big-play-button,
|
||||
.vjs-big-play-button:hover,
|
||||
.vjs-big-play-button:focus,
|
||||
&:hover .vjs-big-play-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 10em;
|
||||
}
|
||||
|
||||
.vjs-skip-button {
|
||||
&::before {
|
||||
font-size: 1.8em;
|
||||
line-height: 1.67;
|
||||
}
|
||||
}
|
||||
|
||||
&.vjs-skip-buttons {
|
||||
.vjs-icon-next-item,
|
||||
.vjs-icon-previous-item {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-prev .vjs-icon-previous-item,
|
||||
&-next .vjs-icon-next-item {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-source-selector {
|
||||
.vjs-menu li {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vjs-button > .vjs-icon-placeholder::before {
|
||||
content: "\f110";
|
||||
font-family: VideoJS;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-marker {
|
||||
background-color: rgba(33, 33, 33, 0.8);
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
-webkit-transition: opacity 0.2s ease;
|
||||
-moz-transition: opacity 0.2s ease;
|
||||
transition: opacity 0.2s ease;
|
||||
width: 6px;
|
||||
z-index: 100;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
-webkit-transform: scale(1.3, 1.3);
|
||||
-moz-transform: scale(1.3, 1.3);
|
||||
-o-transform: scale(1.3, 1.3);
|
||||
-ms-transform: scale(1.3, 1.3);
|
||||
transform: scale(1.3, 1.3);
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-marker-tooltip {
|
||||
border-radius: 0.3em;
|
||||
color: white;
|
||||
display: block;
|
||||
float: right;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 10px;
|
||||
height: 50px;
|
||||
padding: 6px 8px 8px 8px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
top: -5.4em;
|
||||
width: 160px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vjs-text-track-settings select {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.vjs-seek-button.skip-back span.vjs-icon-placeholder::before {
|
||||
-ms-transform: none;
|
||||
-webkit-transform: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.vjs-seek-button.skip-forward span.vjs-icon-placeholder::before {
|
||||
-ms-transform: scale(-1, 1);
|
||||
-webkit-transform: scale(-1, 1);
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.vjs-touch-enabled {
|
||||
&.vjs-touch-enabled {
|
||||
&.vjs-has-started .vjs-big-button-group {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
@@ -127,79 +290,6 @@ $sceneTabWidth: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-js {
|
||||
.vjs-control-bar {
|
||||
background: none;
|
||||
|
||||
/* Scales control size */
|
||||
font-size: 15px;
|
||||
|
||||
&::before {
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.4) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
bottom: 0;
|
||||
content: "";
|
||||
height: 10rem;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-time-control {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 0 4px;
|
||||
pointer-events: none;
|
||||
|
||||
.vjs-control-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-duration {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.vjs-remaining-time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vjs-progress-control {
|
||||
bottom: 3rem;
|
||||
margin-left: 1%;
|
||||
position: absolute;
|
||||
width: 98%;
|
||||
}
|
||||
|
||||
.vjs-volume-control {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vjs-vtt-thumbnail-display {
|
||||
border: 2px solid white;
|
||||
border-radius: 2px;
|
||||
bottom: 90px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.vjs-big-play-button,
|
||||
.vjs-big-play-button:hover,
|
||||
.vjs-big-play-button:focus,
|
||||
&:hover .vjs-big-play-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 10em;
|
||||
}
|
||||
|
||||
.vjs-progress-control .vjs-play-progress .vjs-time-tooltip,
|
||||
.vjs-progress-control:hover .vjs-play-progress .vjs-time-tooltip {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-tabs,
|
||||
.scene-player-container {
|
||||
padding-left: 15px;
|
||||
@@ -284,17 +374,6 @@ $sceneTabWidth: 450px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hide-scrubber .scrubber-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* hide scrubber when height is < 450px or width < 576 */
|
||||
@media (max-height: 449px), (max-width: 575px) {
|
||||
.scrubber-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#scrubber-back {
|
||||
float: left;
|
||||
}
|
||||
@@ -362,7 +441,6 @@ $sceneTabWidth: 450px;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
transition: 333ms ease-out;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -384,8 +462,10 @@ $sceneTabWidth: 450px;
|
||||
background-color: #000;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
height: 20px;
|
||||
padding: 0 10px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
@@ -393,6 +473,11 @@ $sceneTabWidth: 450px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
border-top: solid 5px #444;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-left: solid 5px transparent;
|
||||
border-right: solid 5px transparent;
|
||||
@@ -410,7 +495,7 @@ $sceneTabWidth: 450px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 10px;
|
||||
margin-right: 10px;
|
||||
margin: 0 auto;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
text-shadow: 1px 1px black;
|
||||
@@ -421,95 +506,3 @@ $sceneTabWidth: 450px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-skip-button {
|
||||
&::before {
|
||||
font-size: 1.8em;
|
||||
line-height: 1.67;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-skip-buttons {
|
||||
.vjs-icon-next-item,
|
||||
.vjs-icon-previous-item {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-prev .vjs-icon-previous-item,
|
||||
&-next .vjs-icon-next-item {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-source-selector {
|
||||
.vjs-menu li {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vjs-button > .vjs-icon-placeholder::before {
|
||||
content: "\f110";
|
||||
font-family: VideoJS;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-marker {
|
||||
background-color: rgba(33, 33, 33, 0.8);
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
-webkit-transition: opacity 0.2s ease;
|
||||
-moz-transition: opacity 0.2s ease;
|
||||
transition: opacity 0.2s ease;
|
||||
width: 6px;
|
||||
z-index: 100;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
-webkit-transform: scale(1.3, 1.3);
|
||||
-moz-transform: scale(1.3, 1.3);
|
||||
-o-transform: scale(1.3, 1.3);
|
||||
-ms-transform: scale(1.3, 1.3);
|
||||
transform: scale(1.3, 1.3);
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-marker-tooltip {
|
||||
border-radius: 0.3em;
|
||||
color: white;
|
||||
display: block;
|
||||
float: right;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 10px;
|
||||
height: 50px;
|
||||
padding: 6px 8px 8px 8px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
top: -5.4em;
|
||||
width: 160px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vjs-text-track-settings select {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.VideoPlayer
|
||||
.video-js
|
||||
.vjs-seek-button.skip-back
|
||||
span.vjs-icon-placeholder::before {
|
||||
-ms-transform: none;
|
||||
-webkit-transform: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.VideoPlayer
|
||||
.video-js
|
||||
.vjs-seek-button.skip-forward
|
||||
span.vjs-icon-placeholder::before {
|
||||
-ms-transform: scale(-1, 1);
|
||||
-webkit-transform: scale(-1, 1);
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import VideoJS from "video.js";
|
||||
import videojs from "video.js";
|
||||
|
||||
export const VIDEO_PLAYER_ID = "VideoJsPlayer";
|
||||
|
||||
export const getPlayerPosition = () =>
|
||||
VideoJS.getPlayer(VIDEO_PLAYER_ID).currentTime();
|
||||
videojs.getPlayer(VIDEO_PLAYER_ID).currentTime();
|
||||
|
||||
398
ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts
Normal file
398
ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import videojs, { VideoJsPlayer } from "video.js";
|
||||
import { WebVTT } from "videojs-vtt.js";
|
||||
|
||||
export interface IVTTThumbnailsOptions {
|
||||
/**
|
||||
* Source URL to use for thumbnails.
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* Whether to show the timestamp on hover.
|
||||
* @default false
|
||||
*/
|
||||
showTimestamp?: boolean;
|
||||
}
|
||||
|
||||
interface IVTTData {
|
||||
start: number;
|
||||
end: number;
|
||||
style: IVTTStyle | null;
|
||||
}
|
||||
|
||||
interface IVTTStyle {
|
||||
background: string;
|
||||
width: string;
|
||||
height: string;
|
||||
}
|
||||
|
||||
class VTTThumbnailsPlugin extends videojs.getPlugin("plugin") {
|
||||
private source: string | null;
|
||||
private showTimestamp: boolean;
|
||||
|
||||
private progressBar?: HTMLElement;
|
||||
private thumbnailHolder?: HTMLDivElement;
|
||||
|
||||
private showing = false;
|
||||
|
||||
private vttData?: IVTTData[];
|
||||
private lastStyle?: IVTTStyle;
|
||||
|
||||
constructor(player: VideoJsPlayer, options: IVTTThumbnailsOptions) {
|
||||
super(player, options);
|
||||
this.source = options.src ?? null;
|
||||
this.showTimestamp = options.showTimestamp ?? false;
|
||||
|
||||
player.ready(() => {
|
||||
player.addClass("vjs-vtt-thumbnails");
|
||||
this.initializeThumbnails();
|
||||
});
|
||||
}
|
||||
|
||||
src(source: string | null): void {
|
||||
this.resetPlugin();
|
||||
this.source = source;
|
||||
this.initializeThumbnails();
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.resetPlugin();
|
||||
}
|
||||
|
||||
private resetPlugin() {
|
||||
this.showing = false;
|
||||
|
||||
if (this.thumbnailHolder) {
|
||||
this.thumbnailHolder.remove();
|
||||
delete this.thumbnailHolder;
|
||||
}
|
||||
|
||||
if (this.progressBar) {
|
||||
this.progressBar.removeEventListener(
|
||||
"pointerenter",
|
||||
this.onBarPointerEnter
|
||||
);
|
||||
this.progressBar.removeEventListener(
|
||||
"pointermove",
|
||||
this.onBarPointerMove
|
||||
);
|
||||
this.progressBar.removeEventListener(
|
||||
"pointerleave",
|
||||
this.onBarPointerLeave
|
||||
);
|
||||
|
||||
delete this.progressBar;
|
||||
}
|
||||
|
||||
delete this.vttData;
|
||||
delete this.lastStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap the plugin.
|
||||
*/
|
||||
private initializeThumbnails() {
|
||||
if (!this.source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = this.getBaseUrl();
|
||||
const url = this.getFullyQualifiedUrl(this.source, baseUrl);
|
||||
|
||||
this.getVttFile(url).then((data) => {
|
||||
this.vttData = this.processVtt(data);
|
||||
this.setupThumbnailElement();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a base URL should we require one.
|
||||
*/
|
||||
private getBaseUrl() {
|
||||
return [
|
||||
window.location.protocol,
|
||||
"//",
|
||||
window.location.hostname,
|
||||
window.location.port ? ":" + window.location.port : "",
|
||||
window.location.pathname,
|
||||
]
|
||||
.join("")
|
||||
.split(/([^\/]*)$/gi)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs the contents of the VTT file.
|
||||
*/
|
||||
private getVttFile(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = new XMLHttpRequest();
|
||||
|
||||
req.addEventListener("load", () => {
|
||||
resolve(req.responseText);
|
||||
});
|
||||
req.addEventListener("error", (e) => {
|
||||
reject(e);
|
||||
});
|
||||
req.open("GET", url);
|
||||
req.send();
|
||||
});
|
||||
}
|
||||
|
||||
private setupThumbnailElement() {
|
||||
const progressBar = this.player.$(".vjs-progress-control") as HTMLElement;
|
||||
if (!progressBar) return;
|
||||
this.progressBar = progressBar;
|
||||
|
||||
const thumbHolder = document.createElement("div");
|
||||
thumbHolder.setAttribute("class", "vjs-vtt-thumbnail-display");
|
||||
progressBar.appendChild(thumbHolder);
|
||||
this.thumbnailHolder = thumbHolder;
|
||||
|
||||
if (!this.showTimestamp) {
|
||||
this.player.$(".vjs-mouse-display")?.classList.add("vjs-hidden");
|
||||
}
|
||||
|
||||
progressBar.addEventListener("pointerenter", this.onBarPointerEnter);
|
||||
progressBar.addEventListener("pointerleave", this.onBarPointerLeave);
|
||||
}
|
||||
|
||||
private onBarPointerEnter = () => {
|
||||
this.showThumbnailHolder();
|
||||
this.progressBar?.addEventListener("pointermove", this.onBarPointerMove);
|
||||
};
|
||||
|
||||
private onBarPointerMove = (e: Event) => {
|
||||
const { progressBar } = this;
|
||||
if (!progressBar) return;
|
||||
|
||||
this.showThumbnailHolder();
|
||||
this.updateThumbnailStyle(
|
||||
videojs.dom.getPointerPosition(progressBar, e).x,
|
||||
progressBar.offsetWidth
|
||||
);
|
||||
};
|
||||
|
||||
private onBarPointerLeave = () => {
|
||||
this.hideThumbnailHolder();
|
||||
this.progressBar?.removeEventListener("pointermove", this.onBarPointerMove);
|
||||
};
|
||||
|
||||
private getStyleForTime(time: number) {
|
||||
if (!this.vttData) return null;
|
||||
|
||||
for (const element of this.vttData) {
|
||||
const item = element;
|
||||
|
||||
if (time >= item.start && time < item.end) {
|
||||
return item.style;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private showThumbnailHolder() {
|
||||
if (this.thumbnailHolder && !this.showing) {
|
||||
this.showing = true;
|
||||
this.thumbnailHolder.style.opacity = "1";
|
||||
}
|
||||
}
|
||||
|
||||
private hideThumbnailHolder() {
|
||||
if (this.thumbnailHolder && this.showing) {
|
||||
this.showing = false;
|
||||
this.thumbnailHolder.style.opacity = "0";
|
||||
}
|
||||
}
|
||||
|
||||
private updateThumbnailStyle(percent: number, width: number) {
|
||||
if (!this.thumbnailHolder) return;
|
||||
|
||||
const duration = this.player.duration();
|
||||
const time = percent * duration;
|
||||
const currentStyle = this.getStyleForTime(time);
|
||||
|
||||
if (!currentStyle) {
|
||||
this.hideThumbnailHolder();
|
||||
return;
|
||||
}
|
||||
|
||||
const xPos = percent * width;
|
||||
const thumbnailWidth = parseInt(currentStyle.width, 10);
|
||||
const halfThumbnailWidth = thumbnailWidth >> 1;
|
||||
const marginRight = width - (xPos + halfThumbnailWidth);
|
||||
const marginLeft = xPos - halfThumbnailWidth;
|
||||
|
||||
if (marginLeft > 0 && marginRight > 0) {
|
||||
this.thumbnailHolder.style.transform =
|
||||
"translateX(" + (xPos - halfThumbnailWidth) + "px)";
|
||||
} else if (marginLeft <= 0) {
|
||||
this.thumbnailHolder.style.transform = "translateX(" + 0 + "px)";
|
||||
} else if (marginRight <= 0) {
|
||||
this.thumbnailHolder.style.transform =
|
||||
"translateX(" + (width - thumbnailWidth) + "px)";
|
||||
}
|
||||
|
||||
if (this.lastStyle && this.lastStyle === currentStyle) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastStyle = currentStyle;
|
||||
|
||||
Object.assign(this.thumbnailHolder.style, currentStyle);
|
||||
}
|
||||
|
||||
private processVtt(data: string) {
|
||||
const processedVtts: IVTTData[] = [];
|
||||
|
||||
const parser = new WebVTT.Parser(window, WebVTT.StringDecoder());
|
||||
parser.oncue = (cue: VTTCue) => {
|
||||
processedVtts.push({
|
||||
start: cue.startTime,
|
||||
end: cue.endTime,
|
||||
style: this.getVttStyle(cue.text),
|
||||
});
|
||||
};
|
||||
parser.parse(data);
|
||||
parser.flush();
|
||||
|
||||
return processedVtts;
|
||||
}
|
||||
|
||||
private getFullyQualifiedUrl(path: string, base: string) {
|
||||
if (path.indexOf("//") >= 0) {
|
||||
// We have a fully qualified path.
|
||||
return path;
|
||||
}
|
||||
|
||||
if (base.indexOf("//") === 0) {
|
||||
// We don't have a fully qualified path, but need to
|
||||
// be careful with trimming.
|
||||
return [base.replace(/\/$/gi, ""), this.trim(path, "/")].join("/");
|
||||
}
|
||||
|
||||
if (base.indexOf("//") > 0) {
|
||||
// We don't have a fully qualified path, and should
|
||||
// trim both sides of base and path.
|
||||
return [this.trim(base, "/"), this.trim(path, "/")].join("/");
|
||||
}
|
||||
|
||||
// If all else fails.
|
||||
return path;
|
||||
}
|
||||
|
||||
private getPropsFromDef(def: string) {
|
||||
const match = def.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
image: match[1],
|
||||
x: match[2],
|
||||
y: match[3],
|
||||
w: match[4],
|
||||
h: match[5],
|
||||
};
|
||||
}
|
||||
|
||||
private getVttStyle(vttImageDef: string) {
|
||||
// If there isn't a protocol, use the VTT source URL.
|
||||
let baseSplit: string;
|
||||
|
||||
if (this.source === null) {
|
||||
baseSplit = this.getBaseUrl();
|
||||
} else if (this.source.indexOf("//") >= 0) {
|
||||
baseSplit = this.source.split(/([^\/]*)$/gi)[0];
|
||||
} else {
|
||||
baseSplit = this.getBaseUrl() + this.source.split(/([^\/]*)$/gi)[0];
|
||||
}
|
||||
|
||||
vttImageDef = this.getFullyQualifiedUrl(vttImageDef, baseSplit);
|
||||
|
||||
const imageProps = this.getPropsFromDef(vttImageDef);
|
||||
if (!imageProps) return null;
|
||||
|
||||
return {
|
||||
background:
|
||||
'url("' +
|
||||
imageProps.image +
|
||||
'") no-repeat -' +
|
||||
imageProps.x +
|
||||
"px -" +
|
||||
imageProps.y +
|
||||
"px",
|
||||
width: imageProps.w + "px",
|
||||
height: imageProps.h + "px",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* trim
|
||||
*
|
||||
* @param str source string
|
||||
* @param charlist characters to trim from text
|
||||
* @return trimmed string
|
||||
*/
|
||||
private trim(str: string, charlist: string) {
|
||||
let whitespace = [
|
||||
" ",
|
||||
"\n",
|
||||
"\r",
|
||||
"\t",
|
||||
"\f",
|
||||
"\x0b",
|
||||
"\xa0",
|
||||
"\u2000",
|
||||
"\u2001",
|
||||
"\u2002",
|
||||
"\u2003",
|
||||
"\u2004",
|
||||
"\u2005",
|
||||
"\u2006",
|
||||
"\u2007",
|
||||
"\u2008",
|
||||
"\u2009",
|
||||
"\u200a",
|
||||
"\u200b",
|
||||
"\u2028",
|
||||
"\u2029",
|
||||
"\u3000",
|
||||
].join("");
|
||||
let l = 0;
|
||||
|
||||
str += "";
|
||||
if (charlist) {
|
||||
whitespace = (charlist + "").replace(/([[\]().?/*{}+$^:])/g, "$1");
|
||||
}
|
||||
|
||||
l = str.length;
|
||||
for (let i = 0; i < l; i++) {
|
||||
if (whitespace.indexOf(str.charAt(i)) === -1) {
|
||||
str = str.substring(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
l = str.length;
|
||||
for (let i = l - 1; i >= 0; i--) {
|
||||
if (whitespace.indexOf(str.charAt(i)) === -1) {
|
||||
str = str.substring(0, i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return whitespace.indexOf(str.charAt(0)) === -1 ? str : "";
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin with video.js.
|
||||
videojs.registerPlugin("vttThumbnails", VTTThumbnailsPlugin);
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
declare module "video.js" {
|
||||
interface VideoJsPlayer {
|
||||
vttThumbnails: () => VTTThumbnailsPlugin;
|
||||
}
|
||||
interface VideoJsPlayerPluginOptions {
|
||||
vttThumbnails?: IVTTThumbnailsOptions;
|
||||
}
|
||||
}
|
||||
|
||||
export default VTTThumbnailsPlugin;
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Tab, Nav, Dropdown, Button, ButtonGroup } from "react-bootstrap";
|
||||
import queryString from "query-string";
|
||||
import React, { useEffect, useState, useMemo, useContext, lazy } from "react";
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useContext,
|
||||
lazy,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useParams, useLocation, useHistory, Link } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
@@ -60,8 +67,9 @@ interface IProps {
|
||||
onQueueNext: () => void;
|
||||
onQueuePrevious: () => void;
|
||||
onQueueRandom: () => void;
|
||||
onDelete: () => void;
|
||||
continuePlaylist: boolean;
|
||||
playScene: (sceneID: string, page?: number) => void;
|
||||
loadScene: (sceneID: string) => void;
|
||||
queueHasMoreScenes: () => boolean;
|
||||
onQueueMoreScenes: () => void;
|
||||
onQueueLessScenes: () => void;
|
||||
@@ -79,8 +87,9 @@ const ScenePage: React.FC<IProps> = ({
|
||||
onQueueNext,
|
||||
onQueuePrevious,
|
||||
onQueueRandom,
|
||||
onDelete,
|
||||
continuePlaylist,
|
||||
playScene,
|
||||
loadScene,
|
||||
queueHasMoreScenes,
|
||||
onQueueMoreScenes,
|
||||
onQueueLessScenes,
|
||||
@@ -89,7 +98,6 @@ const ScenePage: React.FC<IProps> = ({
|
||||
setCollapsed,
|
||||
setContinuePlaylist,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
const [updateScene] = useSceneUpdate();
|
||||
@@ -216,7 +224,7 @@ const ScenePage: React.FC<IProps> = ({
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
history.push("/scenes");
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,14 +408,14 @@ const ScenePage: React.FC<IProps> = ({
|
||||
currentID={scene.id}
|
||||
continue={continuePlaylist}
|
||||
setContinue={setContinuePlaylist}
|
||||
onSceneClicked={(sceneID) => playScene(sceneID)}
|
||||
onSceneClicked={loadScene}
|
||||
onNext={onQueueNext}
|
||||
onPrevious={onQueuePrevious}
|
||||
onRandom={onQueueRandom}
|
||||
start={queueStart}
|
||||
hasMoreScenes={queueHasMoreScenes()}
|
||||
onLessScenes={() => onQueueLessScenes()}
|
||||
onMoreScenes={() => onQueueMoreScenes()}
|
||||
onLessScenes={onQueueLessScenes}
|
||||
onMoreScenes={onQueueMoreScenes}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="scene-markers-panel">
|
||||
@@ -441,7 +449,7 @@ const ScenePage: React.FC<IProps> = ({
|
||||
isVisible={activeTabKey === "scene-edit-panel"}
|
||||
scene={scene}
|
||||
onDelete={() => setIsDeleteAlertOpen(true)}
|
||||
onUpdate={() => refetch()}
|
||||
onUpdate={refetch}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
</Tab.Content>
|
||||
@@ -468,7 +476,7 @@ const ScenePage: React.FC<IProps> = ({
|
||||
>
|
||||
<div className="d-none d-xl-block">
|
||||
{scene.studio && (
|
||||
<h1 className="text-center">
|
||||
<h1 className="mt-3 text-center">
|
||||
<Link to={`/studios/${scene.studio.id}`}>
|
||||
<img
|
||||
src={scene.studio.image_path ?? ""}
|
||||
@@ -483,11 +491,7 @@ const ScenePage: React.FC<IProps> = ({
|
||||
{renderTabs()}
|
||||
</div>
|
||||
<div className="scene-divider d-none d-xl-block">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCollapsed(!collapsed);
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => setCollapsed(!collapsed)}>
|
||||
{getCollapseButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -508,59 +512,64 @@ const SceneLoader: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const { data, loading, refetch } = useFindScene(id ?? "");
|
||||
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [continuePlaylist, setContinuePlaylist] = useState(false);
|
||||
const [showScrubber, setShowScrubber] = useState(
|
||||
configuration?.interface.showScrubber ?? true
|
||||
);
|
||||
|
||||
const sceneQueue = useMemo(
|
||||
() => SceneQueue.fromQueryParameters(location.search),
|
||||
const queryParams = useMemo(
|
||||
() => queryString.parse(location.search, { decode: false }),
|
||||
[location.search]
|
||||
);
|
||||
const sceneQueue = useMemo(
|
||||
() => SceneQueue.fromQueryParameters(queryParams),
|
||||
[queryParams]
|
||||
);
|
||||
const queryContinue = useMemo(() => {
|
||||
let cont = queryParams.continue;
|
||||
if (cont !== undefined) {
|
||||
return cont === "true";
|
||||
} else {
|
||||
return !!configuration?.interface.continuePlaylistDefault;
|
||||
}
|
||||
}, [configuration?.interface.continuePlaylistDefault, queryParams.continue]);
|
||||
|
||||
const [queueScenes, setQueueScenes] = useState<QueuedScene[]>([]);
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [continuePlaylist, setContinuePlaylist] = useState(queryContinue);
|
||||
const [hideScrubber, setHideScrubber] = useState(
|
||||
!(configuration?.interface.showScrubber ?? true)
|
||||
);
|
||||
|
||||
const _setTimestamp = useRef<(value: number) => void>();
|
||||
const initialTimestamp = useMemo(() => {
|
||||
const t = Array.isArray(queryParams.t) ? queryParams.t[0] : queryParams.t;
|
||||
return Number.parseInt(t ?? "0", 10);
|
||||
}, [queryParams]);
|
||||
|
||||
const [queueTotal, setQueueTotal] = useState(0);
|
||||
const [queueStart, setQueueStart] = useState(1);
|
||||
|
||||
const queryParams = useMemo(() => queryString.parse(location.search), [
|
||||
location.search,
|
||||
]);
|
||||
|
||||
function getInitialTimestamp() {
|
||||
const params = queryString.parse(location.search);
|
||||
const initialTimestamp = params?.t ?? "0";
|
||||
return Number.parseInt(
|
||||
Array.isArray(initialTimestamp) ? initialTimestamp[0] : initialTimestamp,
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
const autoplay = queryParams?.autoplay === "true";
|
||||
const autoPlayOnSelected =
|
||||
configuration?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
const autoplay = queryParams.autoplay === "true";
|
||||
const currentQueueIndex = queueScenes
|
||||
? queueScenes.findIndex((s) => s.id === id)
|
||||
: -1;
|
||||
|
||||
useEffect(() => {
|
||||
setContinuePlaylist(queryParams?.continue === "true");
|
||||
}, [queryParams]);
|
||||
function getSetTimestamp(fn: (value: number) => void) {
|
||||
_setTimestamp.current = fn;
|
||||
}
|
||||
|
||||
function setTimestamp(value: number) {
|
||||
if (_setTimestamp.current) {
|
||||
_setTimestamp.current(value);
|
||||
}
|
||||
}
|
||||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
Mousetrap.bind(".", () => setShowScrubber(!showScrubber));
|
||||
Mousetrap.bind(".", () => setHideScrubber((value) => !value));
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind(".");
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// reset timestamp after notifying player
|
||||
if (timestamp !== -1) setTimestamp(-1);
|
||||
}, [timestamp]);
|
||||
}, []);
|
||||
|
||||
async function getQueueFilterScenes(filter: ListFilterModel) {
|
||||
const query = await queryFindScenes(filter);
|
||||
@@ -624,25 +633,41 @@ const SceneLoader: React.FC = () => {
|
||||
// don't change queue start
|
||||
}
|
||||
|
||||
function playScene(sceneID: string, newPage?: number) {
|
||||
sceneQueue.playScene(history, sceneID, {
|
||||
function loadScene(sceneID: string, autoPlay?: boolean, newPage?: number) {
|
||||
const sceneLink = sceneQueue.makeLink(sceneID, {
|
||||
newPage,
|
||||
autoPlay: autoPlayOnSelected,
|
||||
autoPlay,
|
||||
continue: continuePlaylist,
|
||||
});
|
||||
history.replace(sceneLink);
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (
|
||||
continuePlaylist &&
|
||||
queueScenes &&
|
||||
currentQueueIndex >= 0 &&
|
||||
currentQueueIndex < queueScenes.length - 1
|
||||
) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id);
|
||||
} else {
|
||||
history.push("/scenes");
|
||||
}
|
||||
}
|
||||
|
||||
function onQueueNext() {
|
||||
if (!queueScenes) return;
|
||||
|
||||
if (currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1) {
|
||||
playScene(queueScenes[currentQueueIndex + 1].id);
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
function onQueuePrevious() {
|
||||
if (!queueScenes) return;
|
||||
|
||||
if (currentQueueIndex > 0) {
|
||||
playScene(queueScenes[currentQueueIndex - 1].id);
|
||||
loadScene(queueScenes[currentQueueIndex - 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,28 +685,45 @@ const SceneLoader: React.FC = () => {
|
||||
filterCopy.currentPage = page;
|
||||
const queryResults = await queryFindScenes(filterCopy);
|
||||
if (queryResults.data.findScenes.scenes.length > index) {
|
||||
const { id: sceneID } = queryResults!.data!.findScenes!.scenes[index];
|
||||
const { id: sceneID } = queryResults.data.findScenes.scenes[index];
|
||||
// navigate to the image player page
|
||||
playScene(sceneID, page);
|
||||
loadScene(sceneID, undefined, page);
|
||||
}
|
||||
} else {
|
||||
const index = Math.floor(Math.random() * queueTotal);
|
||||
playScene(queueScenes[index].id);
|
||||
loadScene(queueScenes[index].id);
|
||||
}
|
||||
}
|
||||
|
||||
function onComplete() {
|
||||
// load the next scene if we're autoplaying
|
||||
if (!queueScenes) return;
|
||||
|
||||
// load the next scene if we're continuing
|
||||
if (continuePlaylist) {
|
||||
onQueueNext();
|
||||
if (
|
||||
currentQueueIndex >= 0 &&
|
||||
currentQueueIndex < queueScenes.length - 1
|
||||
) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
if (!loading && !data?.findScene)
|
||||
return <ErrorMessage error={`No scene found with id ${id}.`} />;
|
||||
*/
|
||||
function onNext() {
|
||||
if (!queueScenes) return;
|
||||
|
||||
if (currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id, true);
|
||||
}
|
||||
}
|
||||
|
||||
function onPrevious() {
|
||||
if (!queueScenes) return;
|
||||
|
||||
if (currentQueueIndex > 0) {
|
||||
loadScene(queueScenes[currentQueueIndex - 1].id, true);
|
||||
}
|
||||
}
|
||||
|
||||
const scene = data?.findScene;
|
||||
|
||||
@@ -694,11 +736,12 @@ const SceneLoader: React.FC = () => {
|
||||
setTimestamp={setTimestamp}
|
||||
queueScenes={queueScenes ?? []}
|
||||
queueStart={queueStart}
|
||||
onDelete={onDelete}
|
||||
onQueueNext={onQueueNext}
|
||||
onQueuePrevious={onQueuePrevious}
|
||||
onQueueRandom={onQueueRandom}
|
||||
continuePlaylist={continuePlaylist}
|
||||
playScene={playScene}
|
||||
loadScene={loadScene}
|
||||
queueHasMoreScenes={queueHasMoreScenes}
|
||||
onQueueLessScenes={onQueueLessScenes}
|
||||
onQueueMoreScenes={onQueueMoreScenes}
|
||||
@@ -709,25 +752,19 @@ const SceneLoader: React.FC = () => {
|
||||
) : (
|
||||
<div className="scene-tabs" />
|
||||
)}
|
||||
<div
|
||||
className={`scene-player-container ${collapsed ? "expanded" : ""} ${
|
||||
!showScrubber ? "hide-scrubber" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`scene-player-container ${collapsed ? "expanded" : ""}`}>
|
||||
<ScenePlayer
|
||||
key="ScenePlayer"
|
||||
className="w-100 m-sm-auto no-gutter"
|
||||
scene={scene}
|
||||
timestamp={timestamp}
|
||||
hideScrubberOverride={hideScrubber}
|
||||
autoplay={autoplay}
|
||||
permitLoop={!continuePlaylist}
|
||||
initialTimestamp={initialTimestamp}
|
||||
sendSetTimestamp={getSetTimestamp}
|
||||
onComplete={onComplete}
|
||||
onNext={
|
||||
currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1
|
||||
? onQueueNext
|
||||
: undefined
|
||||
}
|
||||
onPrevious={currentQueueIndex > 0 ? onQueuePrevious : undefined}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook";
|
||||
import Tagger from "src/components/Tagger";
|
||||
import { SceneQueue } from "src/models/sceneQueue";
|
||||
import { IPlaySceneOptions, SceneQueue } from "src/models/sceneQueue";
|
||||
import { WallPanel } from "../Wall/WallPanel";
|
||||
import { SceneListTable } from "./SceneListTable";
|
||||
import { EditScenesDialog } from "./EditScenesDialog";
|
||||
@@ -108,6 +108,14 @@ export const SceneList: React.FC<ISceneList> = ({
|
||||
persistState,
|
||||
});
|
||||
|
||||
function playScene(
|
||||
queue: SceneQueue,
|
||||
sceneID: string,
|
||||
options: IPlaySceneOptions
|
||||
) {
|
||||
history.push(queue.makeLink(sceneID, options));
|
||||
}
|
||||
|
||||
async function playSelected(
|
||||
result: FindScenesQueryResult,
|
||||
filter: ListFilterModel,
|
||||
@@ -118,9 +126,7 @@ export const SceneList: React.FC<ISceneList> = ({
|
||||
const queue = SceneQueue.fromSceneIDList(sceneIDs);
|
||||
const autoPlay =
|
||||
config.configuration?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
const cont =
|
||||
config.configuration?.interface.continuePlaylistDefault ?? false;
|
||||
queue.playScene(history, sceneIDs[0], { autoPlay, continue: cont });
|
||||
playScene(queue, sceneIDs[0], { autoPlay });
|
||||
}
|
||||
|
||||
async function playRandom(
|
||||
@@ -142,18 +148,12 @@ export const SceneList: React.FC<ISceneList> = ({
|
||||
filterCopy.sortBy = "random";
|
||||
const queryResults = await queryFindScenes(filterCopy);
|
||||
if (queryResults.data.findScenes.scenes.length > index) {
|
||||
const { id } = queryResults!.data!.findScenes!.scenes[index];
|
||||
const { id } = queryResults.data.findScenes.scenes[index];
|
||||
// navigate to the image player page
|
||||
const queue = SceneQueue.fromListFilterModel(filterCopy);
|
||||
const autoPlay =
|
||||
config.configuration?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
const cont =
|
||||
config.configuration?.interface.continuePlaylistDefault ?? false;
|
||||
queue.playScene(history, id, {
|
||||
sceneIndex: index,
|
||||
autoPlay,
|
||||
continue: cont,
|
||||
});
|
||||
playScene(queue, id, { sceneIndex: index, autoPlay });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
ui/v2.5/src/models/react-jw-player.d.ts
vendored
5
ui/v2.5/src/models/react-jw-player.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
declare module "react-jw-player" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ReactJSPlayer: any;
|
||||
export default ReactJSPlayer;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import queryString from "query-string";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import queryString, { ParsedQuery } from "query-string";
|
||||
import { FilterMode, Scene } from "src/core/generated-graphql";
|
||||
import { ListFilterModel } from "./list-filter/filter";
|
||||
import { SceneListFilterOptions } from "./list-filter/scenes";
|
||||
@@ -77,18 +76,17 @@ export class SceneQueue {
|
||||
return "";
|
||||
}
|
||||
|
||||
public static fromQueryParameters(params: string) {
|
||||
public static fromQueryParameters(params: ParsedQuery<string>) {
|
||||
const ret = new SceneQueue();
|
||||
const parsed = queryString.parse(params, { decode: false });
|
||||
const translated = {
|
||||
sortby: parsed.qsort,
|
||||
sortdir: parsed.qsortd,
|
||||
q: parsed.qfq,
|
||||
p: parsed.qfp,
|
||||
c: parsed.qfc,
|
||||
sortby: params.qsort,
|
||||
sortdir: params.qsortd,
|
||||
q: params.qfq,
|
||||
p: params.qfp,
|
||||
c: params.qfc,
|
||||
};
|
||||
|
||||
if (parsed.qfp) {
|
||||
if (params.qfp) {
|
||||
const decoded = ListFilterModel.decodeQueryParameters(translated);
|
||||
const query = new ListFilterModel(
|
||||
FilterMode.Scenes,
|
||||
@@ -96,30 +94,26 @@ export class SceneQueue {
|
||||
);
|
||||
query.configureFromQueryParameters(decoded);
|
||||
ret.query = query;
|
||||
} else if (parsed.qs) {
|
||||
} else if (params.qs) {
|
||||
// must be scene list
|
||||
ret.sceneIDs = Array.isArray(parsed.qs)
|
||||
? parsed.qs.map((v) => Number(v))
|
||||
: [Number(parsed.qs)];
|
||||
ret.sceneIDs = Array.isArray(params.qs)
|
||||
? params.qs.map((v) => Number(v))
|
||||
: [Number(params.qs)];
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
public playScene(
|
||||
history: RouteComponentProps["history"],
|
||||
sceneID: string,
|
||||
options?: IPlaySceneOptions
|
||||
) {
|
||||
history.replace(this.makeLink(sceneID, options));
|
||||
public makeLink(sceneID: string, options: IPlaySceneOptions) {
|
||||
let params = [
|
||||
this.makeQueryParameters(options.sceneIndex, options.newPage),
|
||||
];
|
||||
if (options.autoPlay !== undefined) {
|
||||
params.push("autoplay=" + options.autoPlay);
|
||||
}
|
||||
if (options.continue !== undefined) {
|
||||
params.push("continue=" + options.continue);
|
||||
}
|
||||
|
||||
public makeLink(sceneID: string, options?: IPlaySceneOptions) {
|
||||
const params = [
|
||||
this.makeQueryParameters(options?.sceneIndex, options?.newPage),
|
||||
options?.autoPlay ? "autoplay=true" : "",
|
||||
options?.continue ? "continue=true" : "",
|
||||
].filter((param) => !!param);
|
||||
return `/scenes/${sceneID}${params.length ? "?" + params.join("&") : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1680,24 +1680,24 @@
|
||||
resolved "https://registry.npmjs.org/@ungap/global-this/-/global-this-0.4.4.tgz"
|
||||
integrity sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA==
|
||||
|
||||
"@videojs/http-streaming@2.12.0":
|
||||
version "2.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@videojs/http-streaming/-/http-streaming-2.12.0.tgz#850069e063e26cf2fa5ed9bb3addfc92fa899f78"
|
||||
integrity sha512-vdQA0lDYBXGJqV2T02AGqg1w4dcgyRoN+bYG+G8uF4DpCEMhEtUI0BA4tRu4/Njar8w/9D5k0a1KX40pcvM3fA==
|
||||
"@videojs/http-streaming@2.14.3":
|
||||
version "2.14.3"
|
||||
resolved "https://registry.yarnpkg.com/@videojs/http-streaming/-/http-streaming-2.14.3.tgz#3277e03b576766decb4fc663e954e18bfa10d2a1"
|
||||
integrity sha512-2tFwxCaNbcEZzQugWf8EERwNMyNtspfHnvxRGRABQs09W/5SqmkWFuGWfUAm4wQKlXGfdPyAJ1338ASl459xAA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@videojs/vhs-utils" "3.0.4"
|
||||
aes-decrypter "3.1.2"
|
||||
"@videojs/vhs-utils" "3.0.5"
|
||||
aes-decrypter "3.1.3"
|
||||
global "^4.4.0"
|
||||
m3u8-parser "4.7.0"
|
||||
mpd-parser "0.19.2"
|
||||
mux.js "5.14.1"
|
||||
m3u8-parser "4.7.1"
|
||||
mpd-parser "0.21.1"
|
||||
mux.js "6.0.1"
|
||||
video.js "^6 || ^7"
|
||||
|
||||
"@videojs/vhs-utils@3.0.4", "@videojs/vhs-utils@^3.0.0", "@videojs/vhs-utils@^3.0.2", "@videojs/vhs-utils@^3.0.3":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.4.tgz#e253eecd8e9318f767e752010d213587f94bb03a"
|
||||
integrity sha512-hui4zOj2I1kLzDgf8QDVxD3IzrwjS/43KiS8IHQO0OeeSsb4pB/lgNt1NG7Dv0wMQfCccUpMVLGcK618s890Yg==
|
||||
"@videojs/vhs-utils@3.0.5", "@videojs/vhs-utils@^3.0.4", "@videojs/vhs-utils@^3.0.5":
|
||||
version "3.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz#665ba70d78258ba1ab977364e2fe9f4d4799c46c"
|
||||
integrity sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
global "^4.4.0"
|
||||
@@ -1748,13 +1748,13 @@ acorn@^7.4.0:
|
||||
resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz"
|
||||
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
|
||||
|
||||
aes-decrypter@3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/aes-decrypter/-/aes-decrypter-3.1.2.tgz#3545546f8e9f6b878640339a242efe221ba7a7cb"
|
||||
integrity sha512-42nRwfQuPRj9R1zqZBdoxnaAmnIFyDi0MNyTVhjdFOd8fifXKKRfwIHIZ6AMn1or4x5WONzjwRTbTWcsIQ0O4A==
|
||||
aes-decrypter@3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/aes-decrypter/-/aes-decrypter-3.1.3.tgz#65ff5f2175324d80c41083b0e135d1464b12ac35"
|
||||
integrity sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@videojs/vhs-utils" "^3.0.0"
|
||||
"@videojs/vhs-utils" "^3.0.5"
|
||||
global "^4.4.0"
|
||||
pkcs7 "^1.0.4"
|
||||
|
||||
@@ -1770,7 +1770,7 @@ ajv-keywords@^3.5.2:
|
||||
resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz"
|
||||
integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==
|
||||
|
||||
ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5:
|
||||
ajv@^6.10.0, ajv@^6.12.4, ajv@^6.12.5:
|
||||
version "6.12.6"
|
||||
resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
|
||||
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
|
||||
@@ -1959,18 +1959,6 @@ asap@~2.0.3:
|
||||
resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz"
|
||||
integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=
|
||||
|
||||
asn1@~0.2.3:
|
||||
version "0.2.6"
|
||||
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d"
|
||||
integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==
|
||||
dependencies:
|
||||
safer-buffer "~2.1.0"
|
||||
|
||||
assert-plus@1.0.0, assert-plus@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
|
||||
integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
|
||||
|
||||
ast-types-flow@^0.0.7:
|
||||
version "0.0.7"
|
||||
resolved "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz"
|
||||
@@ -2014,27 +2002,19 @@ autoprefixer@^9.8.6:
|
||||
postcss "^7.0.32"
|
||||
postcss-value-parser "^4.1.0"
|
||||
|
||||
aws-sign2@~0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
|
||||
integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
|
||||
|
||||
aws4@^1.8.0:
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
|
||||
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
|
||||
|
||||
axe-core@^4.0.2:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.3.tgz"
|
||||
integrity sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ==
|
||||
|
||||
axios@0.24.0:
|
||||
version "0.24.0"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6"
|
||||
integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==
|
||||
axios@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.3.tgz#8274250dada2edf53814ed7db644b9c2866c1e35"
|
||||
integrity sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==
|
||||
dependencies:
|
||||
follow-redirects "^1.14.4"
|
||||
follow-redirects "^1.15.0"
|
||||
form-data "^4.0.0"
|
||||
proxy-from-env "^1.1.0"
|
||||
|
||||
axobject-query@^2.2.0:
|
||||
version "2.2.0"
|
||||
@@ -2139,13 +2119,6 @@ base64-js@^1.3.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
bcrypt-pbkdf@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
|
||||
integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
|
||||
dependencies:
|
||||
tweetnacl "^0.14.3"
|
||||
|
||||
binary-extensions@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz"
|
||||
@@ -2284,11 +2257,6 @@ capital-case@^1.0.4:
|
||||
tslib "^2.0.3"
|
||||
upper-case-first "^2.0.2"
|
||||
|
||||
caseless@~0.12.0:
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
|
||||
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
|
||||
|
||||
ccount@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz"
|
||||
@@ -2522,7 +2490,7 @@ colorette@^1.2.1:
|
||||
resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz"
|
||||
integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
|
||||
|
||||
combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
|
||||
combined-stream@^1.0.8:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz"
|
||||
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||
@@ -2575,11 +2543,6 @@ core-js-pure@^3.0.0:
|
||||
resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.9.1.tgz"
|
||||
integrity sha512-laz3Zx0avrw9a4QEIdmIblnVuJz8W51leY9iLThatCsFawWxC3sE4guASC78JbCin+DkwMpCdp1AVAuzL/GN7A==
|
||||
|
||||
core-util-is@1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
|
||||
integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
|
||||
|
||||
cosmiconfig-toml-loader@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/cosmiconfig-toml-loader/-/cosmiconfig-toml-loader-1.0.0.tgz"
|
||||
@@ -2659,13 +2622,6 @@ damerau-levenshtein@^1.0.6:
|
||||
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791"
|
||||
integrity sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug==
|
||||
|
||||
dashdash@^1.12.0:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
|
||||
integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
|
||||
dataloader@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-2.0.0.tgz#41eaf123db115987e21ca93c005cd7753c55fe6f"
|
||||
@@ -2893,14 +2849,6 @@ duplexer3@^0.1.4:
|
||||
resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz"
|
||||
integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
|
||||
|
||||
ecc-jsbn@~0.1.1:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
||||
integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
|
||||
dependencies:
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.1.0"
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz"
|
||||
@@ -3411,7 +3359,7 @@ execall@^2.0.0:
|
||||
dependencies:
|
||||
clone-regexp "^2.1.0"
|
||||
|
||||
extend@^3.0.0, extend@~3.0.2:
|
||||
extend@^3.0.0:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
|
||||
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
|
||||
@@ -3451,16 +3399,6 @@ extract-react-intl-messages@^4.1.1:
|
||||
sort-keys "^4.0.0"
|
||||
write-json-file "^4.3.0"
|
||||
|
||||
extsprintf@1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
|
||||
integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
|
||||
|
||||
extsprintf@^1.2.0:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07"
|
||||
integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==
|
||||
|
||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
|
||||
@@ -3609,17 +3547,12 @@ flexbin@^0.2.0:
|
||||
resolved "https://registry.npmjs.org/flexbin/-/flexbin-0.2.0.tgz"
|
||||
integrity sha1-ASYwbT1ZX8t9/LhxSbnJWZ/49Ok=
|
||||
|
||||
follow-redirects@^1.14.4:
|
||||
version "1.14.6"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd"
|
||||
integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==
|
||||
follow-redirects@^1.15.0:
|
||||
version "1.15.2"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
|
||||
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
|
||||
|
||||
forever-agent@~0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
|
||||
integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
|
||||
|
||||
form-data@4.0.0:
|
||||
form-data@4.0.0, form-data@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz"
|
||||
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
|
||||
@@ -3637,15 +3570,6 @@ form-data@^3.0.0:
|
||||
combined-stream "^1.0.8"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
form-data@~2.3.2:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
|
||||
integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.6"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
formik@^2.2.6:
|
||||
version "2.2.6"
|
||||
resolved "https://registry.npmjs.org/formik/-/formik-2.2.6.tgz"
|
||||
@@ -3749,13 +3673,6 @@ get-symbol-description@^1.0.0:
|
||||
call-bind "^1.0.2"
|
||||
get-intrinsic "^1.1.1"
|
||||
|
||||
getpass@^0.1.1:
|
||||
version "0.1.7"
|
||||
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
|
||||
integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
|
||||
glob-parent@^5.1.0, glob-parent@^5.1.2, glob-parent@~5.1.0:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
|
||||
@@ -3958,19 +3875,6 @@ graphql@^15.3.0, graphql@^15.4.0:
|
||||
resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.0.tgz"
|
||||
integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA==
|
||||
|
||||
har-schema@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
|
||||
integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
|
||||
|
||||
har-validator@~5.1.3:
|
||||
version "5.1.5"
|
||||
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd"
|
||||
integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==
|
||||
dependencies:
|
||||
ajv "^6.12.3"
|
||||
har-schema "^2.0.0"
|
||||
|
||||
hard-rejection@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz"
|
||||
@@ -4103,15 +4007,6 @@ http-proxy-agent@^4.0.1:
|
||||
agent-base "6"
|
||||
debug "4"
|
||||
|
||||
http-signature@~1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
|
||||
integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
jsprim "^1.2.2"
|
||||
sshpk "^1.7.0"
|
||||
|
||||
https-proxy-agent@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz"
|
||||
@@ -4570,7 +4465,7 @@ is-symbol@^1.0.2, is-symbol@^1.0.3:
|
||||
dependencies:
|
||||
has-symbols "^1.0.1"
|
||||
|
||||
is-typedarray@^1.0.0, is-typedarray@~1.0.0:
|
||||
is-typedarray@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz"
|
||||
integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
|
||||
@@ -4634,11 +4529,6 @@ isomorphic-ws@4.0.1:
|
||||
resolved "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz"
|
||||
integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==
|
||||
|
||||
isstream@~0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
|
||||
integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
|
||||
|
||||
iterall@^1.2.1:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz"
|
||||
@@ -4664,11 +4554,6 @@ js-yaml@^4.0.0:
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
jsbn@~0.1.0:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
||||
integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
|
||||
|
||||
jsesc@^2.5.1:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
|
||||
@@ -4694,11 +4579,6 @@ json-schema-traverse@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
|
||||
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
|
||||
|
||||
json-schema@0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
|
||||
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
|
||||
|
||||
json-stable-stringify-without-jsonify@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||
@@ -4711,11 +4591,6 @@ json-stable-stringify@^1.0.1:
|
||||
dependencies:
|
||||
jsonify "~0.0.0"
|
||||
|
||||
json-stringify-safe@~5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
|
||||
integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
|
||||
|
||||
json-to-pretty-yaml@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz#f4cd0bd0a5e8fe1df25aaf5ba118b099fd992d5b"
|
||||
@@ -4775,16 +4650,6 @@ jsonwebtoken@^8.5.1:
|
||||
ms "^2.1.1"
|
||||
semver "^5.6.0"
|
||||
|
||||
jsprim@^1.2.2:
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb"
|
||||
integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==
|
||||
dependencies:
|
||||
assert-plus "1.0.0"
|
||||
extsprintf "1.3.0"
|
||||
json-schema "0.4.0"
|
||||
verror "1.10.0"
|
||||
|
||||
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.1.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz"
|
||||
@@ -4930,10 +4795,10 @@ load-json-file@^6.2.0:
|
||||
strip-bom "^4.0.0"
|
||||
type-fest "^0.6.0"
|
||||
|
||||
localforage@1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.npmjs.org/localforage/-/localforage-1.9.0.tgz"
|
||||
integrity sha512-rR1oyNrKulpe+VM9cYmcFn6tsHuokyVHFaCM3+osEmxaHTbEk8oQu6eGDfS6DQLWi/N67XRmB8ECG37OES368g==
|
||||
localforage@^1.9.0:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4"
|
||||
integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==
|
||||
dependencies:
|
||||
lie "3.1.1"
|
||||
|
||||
@@ -5099,13 +4964,13 @@ lru-cache@^6.0.0:
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
m3u8-parser@4.7.0:
|
||||
version "4.7.0"
|
||||
resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.7.0.tgz#e01e8ce136098ade1b14ee691ea20fc4dc60abf6"
|
||||
integrity sha512-48l/OwRyjBm+QhNNigEEcRcgbRvnUjL7rxs597HmW9QSNbyNvt+RcZ9T/d9vxi9A9z7EZrB1POtZYhdRlwYQkQ==
|
||||
m3u8-parser@4.7.1:
|
||||
version "4.7.1"
|
||||
resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.7.1.tgz#d6df2c940bb19a01112a04ccc4ff44886a945305"
|
||||
integrity sha512-pbrQwiMiq+MmI9bl7UjtPT3AK603PV9bogNlr83uC+X9IoxqL5E4k7kU7fMQ0dpRgxgeSMygqUa0IMLQNXLBNA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@videojs/vhs-utils" "^3.0.0"
|
||||
"@videojs/vhs-utils" "^3.0.5"
|
||||
global "^4.4.0"
|
||||
|
||||
make-dir@^3.0.0:
|
||||
@@ -5584,11 +5449,6 @@ mime-db@1.46.0:
|
||||
resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz"
|
||||
integrity sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==
|
||||
|
||||
mime-db@1.51.0:
|
||||
version "1.51.0"
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.51.0.tgz#d9ff62451859b18342d960850dc3cfb77e63fb0c"
|
||||
integrity sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==
|
||||
|
||||
mime-types@^2.1.12:
|
||||
version "2.1.29"
|
||||
resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.29.tgz"
|
||||
@@ -5596,13 +5456,6 @@ mime-types@^2.1.12:
|
||||
dependencies:
|
||||
mime-db "1.46.0"
|
||||
|
||||
mime-types@~2.1.19:
|
||||
version "2.1.34"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.34.tgz#5a712f9ec1503511a945803640fafe09d3793c24"
|
||||
integrity sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==
|
||||
dependencies:
|
||||
mime-db "1.51.0"
|
||||
|
||||
mimic-fn@^1.0.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz"
|
||||
@@ -5679,13 +5532,13 @@ mousetrap@^1.6.5:
|
||||
resolved "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz"
|
||||
integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==
|
||||
|
||||
mpd-parser@0.19.2:
|
||||
version "0.19.2"
|
||||
resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-0.19.2.tgz#68611e653cdf2cc1e90688825c4a129b7f9007e0"
|
||||
integrity sha512-M5tAIdtBM2TN+OSTz/37T7V+h9ZLvhyNqq4TNIdtjAQ/Hg8UnMRf5nJQDjffcXag3POXi31yUJQEKOXdcAM/nw==
|
||||
mpd-parser@0.21.1:
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-0.21.1.tgz#4f4834074ed0a8e265d8b04a5d2d7b5045a4fa55"
|
||||
integrity sha512-BxlSXWbKE1n7eyEPBnTEkrzhS3PdmkkKdM1pgKbPnPOH0WFZIc0sPOWi7m0Uo3Wd2a4Or8Qf4ZbS7+ASqQ49fw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@videojs/vhs-utils" "^3.0.2"
|
||||
"@videojs/vhs-utils" "^3.0.5"
|
||||
"@xmldom/xmldom" "^0.7.2"
|
||||
global "^4.4.0"
|
||||
|
||||
@@ -5714,12 +5567,13 @@ mute-stream@0.0.8:
|
||||
resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz"
|
||||
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
|
||||
|
||||
mux.js@5.14.1:
|
||||
version "5.14.1"
|
||||
resolved "https://registry.yarnpkg.com/mux.js/-/mux.js-5.14.1.tgz#209583f454255d9ba2ff1bb61ad5a6867cf61878"
|
||||
integrity sha512-38kA/xjWRDzMbcpHQfhKbJAME8eTZVsb9U2Puk890oGvGqnyu8B/AkKdICKPHkigfqYX9MY20vje88TP14nhog==
|
||||
mux.js@6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/mux.js/-/mux.js-6.0.1.tgz#65ce0f7a961d56c006829d024d772902d28c7755"
|
||||
integrity sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.11.2"
|
||||
global "^4.4.0"
|
||||
|
||||
nanoclone@^0.2.1:
|
||||
version "0.2.1"
|
||||
@@ -5826,11 +5680,6 @@ number-is-nan@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz"
|
||||
integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
|
||||
|
||||
oauth-sign@~0.9.0:
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
|
||||
integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
|
||||
|
||||
object-assign@^4.1.0, object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
||||
@@ -6153,11 +6002,6 @@ path-type@^4.0.0:
|
||||
resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz"
|
||||
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
|
||||
|
||||
performance-now@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
|
||||
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
|
||||
|
||||
picocolors@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
|
||||
@@ -6345,10 +6189,10 @@ property-information@^6.0.0:
|
||||
resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.1.1.tgz#5ca85510a3019726cb9afed4197b7b8ac5926a22"
|
||||
integrity sha512-hrzC564QIl0r0vy4l6MvRLhafmUowhO/O3KgVSoXIbbA2Sz4j8HGpJc6T2cubRVwMwpdiG/vKGfhT4IixmKN9w==
|
||||
|
||||
psl@^1.1.28:
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
|
||||
integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
|
||||
proxy-from-env@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
|
||||
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
|
||||
|
||||
pump@^3.0.0:
|
||||
version "3.0.0"
|
||||
@@ -6358,16 +6202,11 @@ pump@^3.0.0:
|
||||
end-of-stream "^1.1.0"
|
||||
once "^1.3.1"
|
||||
|
||||
punycode@^2.1.0, punycode@^2.1.1:
|
||||
punycode@^2.1.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz"
|
||||
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
|
||||
|
||||
qs@~6.5.2:
|
||||
version "6.5.2"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
|
||||
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
|
||||
|
||||
query-string@6.13.8:
|
||||
version "6.13.8"
|
||||
resolved "https://registry.npmjs.org/query-string/-/query-string-6.13.8.tgz"
|
||||
@@ -6814,32 +6653,6 @@ replaceall@^0.1.6:
|
||||
resolved "https://registry.npmjs.org/replaceall/-/replaceall-0.1.6.tgz"
|
||||
integrity sha1-gdgax663LX9cSUKt8ml6MiBojY4=
|
||||
|
||||
request@^2.88.2:
|
||||
version "2.88.2"
|
||||
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
|
||||
integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
|
||||
dependencies:
|
||||
aws-sign2 "~0.7.0"
|
||||
aws4 "^1.8.0"
|
||||
caseless "~0.12.0"
|
||||
combined-stream "~1.0.6"
|
||||
extend "~3.0.2"
|
||||
forever-agent "~0.6.1"
|
||||
form-data "~2.3.2"
|
||||
har-validator "~5.1.3"
|
||||
http-signature "~1.2.0"
|
||||
is-typedarray "~1.0.0"
|
||||
isstream "~0.1.2"
|
||||
json-stringify-safe "~5.0.1"
|
||||
mime-types "~2.1.19"
|
||||
oauth-sign "~0.9.0"
|
||||
performance-now "^2.1.0"
|
||||
qs "~6.5.2"
|
||||
safe-buffer "^5.1.2"
|
||||
tough-cookie "~2.5.0"
|
||||
tunnel-agent "^0.6.0"
|
||||
uuid "^3.3.2"
|
||||
|
||||
require-directory@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz"
|
||||
@@ -6972,7 +6785,7 @@ sade@^1.7.3:
|
||||
dependencies:
|
||||
mri "^1.1.0"
|
||||
|
||||
safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
|
||||
safe-buffer@^5.0.1, safe-buffer@~5.2.0:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
@@ -6989,7 +6802,7 @@ safe-json-parse@4.0.0:
|
||||
dependencies:
|
||||
rust-result "^1.0.0"
|
||||
|
||||
"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
|
||||
"safer-buffer@>= 2.1.2 < 3":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
@@ -7224,21 +7037,6 @@ sse-z@0.3.0:
|
||||
resolved "https://registry.npmjs.org/sse-z/-/sse-z-0.3.0.tgz"
|
||||
integrity sha512-jfcXynl9oAOS9YJ7iqS2JMUEHOlvrRAD+54CENiWnc4xsuVLQVSgmwf7cwOTcBd/uq3XkQKBGojgvEtVXcJ/8w==
|
||||
|
||||
sshpk@^1.7.0:
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
|
||||
integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
|
||||
dependencies:
|
||||
asn1 "~0.2.3"
|
||||
assert-plus "^1.0.0"
|
||||
bcrypt-pbkdf "^1.0.0"
|
||||
dashdash "^1.12.0"
|
||||
ecc-jsbn "~0.1.1"
|
||||
getpass "^0.1.1"
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.0.2"
|
||||
tweetnacl "~0.14.0"
|
||||
|
||||
"statuses@>= 1.5.0 < 2":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz"
|
||||
@@ -7660,14 +7458,6 @@ totalist@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/totalist/-/totalist-2.0.0.tgz#db6f1e19c0fa63e71339bbb8fba89653c18c7eec"
|
||||
integrity sha512-+Y17F0YzxfACxTyjfhnJQEe7afPA0GSpYlFkl2VFMxYP7jshQf9gXV7cH47EfToBumFThfKBvfAcoUn6fdNeRQ==
|
||||
|
||||
tough-cookie@~2.5.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
|
||||
integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
|
||||
dependencies:
|
||||
psl "^1.1.28"
|
||||
punycode "^2.1.1"
|
||||
|
||||
trim-newlines@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
|
||||
@@ -7741,18 +7531,6 @@ tsutils@^3.21.0:
|
||||
dependencies:
|
||||
tslib "^1.8.1"
|
||||
|
||||
tunnel-agent@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
|
||||
integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
|
||||
version "0.14.5"
|
||||
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
|
||||
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"
|
||||
@@ -8033,11 +7811,6 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2:
|
||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
|
||||
|
||||
uuid@^3.3.2:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
||||
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
|
||||
|
||||
uvu@^0.5.0:
|
||||
version "0.5.2"
|
||||
resolved "https://registry.npmjs.org/uvu/-/uvu-0.5.2.tgz"
|
||||
@@ -8072,15 +7845,6 @@ value-equal@^1.0.1:
|
||||
resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz"
|
||||
integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==
|
||||
|
||||
verror@1.10.0:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
|
||||
integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
core-util-is "1.0.2"
|
||||
extsprintf "^1.2.0"
|
||||
|
||||
vfile-message@^2.0.0:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz"
|
||||
@@ -8117,24 +7881,24 @@ vfile@^5.0.0:
|
||||
unist-util-stringify-position "^3.0.0"
|
||||
vfile-message "^3.0.0"
|
||||
|
||||
"video.js@^6 || ^7", video.js@^7.17.0, "video.js@^7.2.0 || ^6.6.0":
|
||||
version "7.17.0"
|
||||
resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.17.0.tgz#35918cc03748a5680f5d5f1da410e06eeea7786e"
|
||||
integrity sha512-8RbLu9+Pdpep9OTPncUHIvZXFgn/7hKdPnSTE/lGSnlFSucXtTUBp41R7NDwncscMLQ0WgazUbmFlvr4MNWMbA==
|
||||
"video.js@^6 || ^7", video.js@^7.20.3:
|
||||
version "7.20.3"
|
||||
resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.20.3.tgz#5694741346dc683255993e5069daa15d4bacb646"
|
||||
integrity sha512-JMspxaK74LdfWcv69XWhX4rILywz/eInOVPdKefpQiZJSMD5O8xXYueqACP2Q5yqKstycgmmEKlJzZ+kVmDciw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@videojs/http-streaming" "2.12.0"
|
||||
"@videojs/vhs-utils" "^3.0.3"
|
||||
"@videojs/http-streaming" "2.14.3"
|
||||
"@videojs/vhs-utils" "^3.0.4"
|
||||
"@videojs/xhr" "2.6.0"
|
||||
aes-decrypter "3.1.2"
|
||||
aes-decrypter "3.1.3"
|
||||
global "^4.4.0"
|
||||
keycode "^2.2.0"
|
||||
m3u8-parser "4.7.0"
|
||||
mpd-parser "0.19.2"
|
||||
mux.js "5.14.1"
|
||||
m3u8-parser "4.7.1"
|
||||
mpd-parser "0.21.1"
|
||||
mux.js "6.0.1"
|
||||
safe-json-parse "4.0.0"
|
||||
videojs-font "3.2.0"
|
||||
videojs-vtt.js "^0.15.3"
|
||||
videojs-vtt.js "^0.15.4"
|
||||
|
||||
videojs-font@3.2.0:
|
||||
version "3.2.0"
|
||||
@@ -8156,19 +7920,10 @@ videojs-seek-buttons@^2.2.0:
|
||||
global "^4.4.0"
|
||||
video.js "^6 || ^7"
|
||||
|
||||
videojs-vtt-thumbnails-freetube@^0.0.15:
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/videojs-vtt-thumbnails-freetube/-/videojs-vtt-thumbnails-freetube-0.0.15.tgz#5bbc1f98c4d4cffd5b3538e8caab36aca94c86cf"
|
||||
integrity sha512-aRjG6fvsuWCpcFcdhqRbI5HUWw1l7boHRJZoQki+z74uDbys/u8OVo6S/oJgpmog//iToQEKqHjSEisFdVDQlA==
|
||||
dependencies:
|
||||
global "^4.4.0"
|
||||
request "^2.88.2"
|
||||
video.js "^7.2.0 || ^6.6.0"
|
||||
|
||||
videojs-vtt.js@^0.15.3:
|
||||
version "0.15.3"
|
||||
resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.15.3.tgz#84260393b79487fcf195d9372f812d7fab83a993"
|
||||
integrity sha512-5FvVsICuMRx6Hd7H/Y9s9GDeEtYcXQWzGMS+sl4UX3t/zoHp3y+isSfIPRochnTH7h+Bh1ILyC639xy9Z6kPag==
|
||||
videojs-vtt.js@^0.15.4:
|
||||
version "0.15.4"
|
||||
resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.15.4.tgz#5dc5aabcd82ba40c5595469bd855ea8230ca152c"
|
||||
integrity sha512-r6IhM325fcLb1D6pgsMkTQT1PpFdUdYZa1iqk7wJEu+QlibBwATPfPc9Bg8Jiym0GE5yP1AG2rMLu+QMVWkYtA==
|
||||
dependencies:
|
||||
global "^4.3.1"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user