diff --git a/graphql/documents/data/scene-slim.graphql b/graphql/documents/data/scene-slim.graphql index 362ac4092..42c2538df 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/graphql/documents/data/scene-slim.graphql @@ -20,7 +20,6 @@ fragment SlimSceneData on Scene { stream webp vtt - chapters_vtt sprite funscript interactive_heatmap diff --git a/graphql/documents/data/scene.graphql b/graphql/documents/data/scene.graphql index 13a672900..9a8a6ac61 100644 --- a/graphql/documents/data/scene.graphql +++ b/graphql/documents/data/scene.graphql @@ -26,7 +26,6 @@ fragment SceneData on Scene { stream webp vtt - chapters_vtt sprite funscript interactive_heatmap diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 13c22cdc5..594551533 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -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 diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 624b08b50..0f9dc2b19 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -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, diff --git a/internal/api/routes_scene.go b/internal/api/routes_scene.go index 27693a517..b2a50c07f 100644 --- a/internal/api/routes_scene.go +++ b/internal/api/routes_scene.go @@ -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) diff --git a/internal/api/urlbuilders/scene.go b/internal/api/urlbuilders/scene.go index 014daec95..732b2746f 100644 --- a/internal/api/urlbuilders/scene.go +++ b/internal/api/urlbuilders/scene.go @@ -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" } diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 8b6a982bd..afe858eb3 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -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", diff --git a/ui/v2.5/src/@types/landscape-fullscreen.d.ts b/ui/v2.5/src/@types/landscape-fullscreen.d.ts new file mode 100644 index 000000000..5cafaceef --- /dev/null +++ b/ui/v2.5/src/@types/landscape-fullscreen.d.ts @@ -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 }; + } + } +} diff --git a/ui/v2.5/src/@types/videojs-vtt.d.ts b/ui/v2.5/src/@types/videojs-vtt.d.ts new file mode 100644 index 000000000..7140e5b6e --- /dev/null +++ b/ui/v2.5/src/@types/videojs-vtt.d.ts @@ -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; +} diff --git a/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts b/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts index 0b306220c..7d2101187 100644 --- a/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts +++ b/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts @@ -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?: {}; } } diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index b288471f5..b1dc302cb 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -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 = ({ className, - autoplay, scene, - timestamp, + hideScrubberOverride, + autoplay, permitLoop = true, + initialTimestamp: _initialTimestamp, + sendSetTimestamp, onComplete, onNext, onPrevious, @@ -137,11 +140,11 @@ export const ScenePlayer: React.FC = ({ const { configuration } = useContext(ConfigurationContext); const config = configuration?.interface; const videoRef = useRef(null); - const playerRef = useRef(); - const sceneId = useRef(); - const skipButtonsRef = useRef(); + const playerRef = useRef(); + const sceneId = useRef(); const [time, setTime] = useState(0); + const [ready, setReady] = useState(false); const { interactive: interactiveClient, @@ -151,9 +154,12 @@ export const ScenePlayer: React.FC = ({ 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 = ({ ); 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 = ({ ); useEffect(() => { - if (playerRef.current && timestamp >= 0) { - const player = playerRef.current; - player.play()?.then(() => { - player.currentTime(timestamp); - }); + if (hideScrubberOverride || fullscreen) { + setShowScrubber(false); + return; } - }, [timestamp]); + + 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(() => { - if (playerRef.current) { + sendSetTimestamp((value: number) => { const player = playerRef.current; - player.loop(looping); - interactiveClient.setLooping(looping); - } - }, [looping, interactiveClient]); + if (player && value >= 0) { + player.play()?.then(() => { + player.currentTime(value); + }); + } + }); + }, [sendSetTimestamp]); + // Initialize VideoJS player useEffect(() => { - const videoElement = videoRef.current; - if (!videoElement) return; - const options: VideoJsPlayerOptions = { controls: true, controlBar: { @@ -208,15 +221,29 @@ export const ScenePlayer: React.FC = ({ 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 = ({ }); 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 = ({ scene?.paths.funscript, ]); + // Player event handlers useEffect(() => { - if (skipButtonsRef.current) { - skipButtonsRef.current.setForwardHandler(onNext); - skipButtonsRef.current.setBackwardHandler(onPrevious); - } - }, [onNext, onPrevious]); - - useEffect(() => { - const player = playerRef.current; - if (player) { - player.seekButtons({ - forward: 10, - back: 10, - }); - - skipButtonsRef.current = player.skipButtons() ?? undefined; - - player.focus(); - } - - // Video player destructor - return () => { - if (playerRef.current) { - playerRef.current.dispose(); - playerRef.current = undefined; + 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()); + } + + const player = playerRef.current; + if (!player) return; + + player.on("canplay", canplay); + player.on("playing", playing); + player.on("loadstart", loadstart); + player.on("fullscreenchange", fullscreenchange); + + return () => { + player.off("canplay", canplay); + player.off("playing", playing); + player.off("loadstart", loadstart); + player.off("fullscreenchange", fullscreenchange); }; }, []); - - const start = useCallback(() => { - const player = playerRef.current; - if (player && scene) { - started.current = true; - - player - .play() - ?.then(() => { - if (initialTimestamp > 0) { - player.currentTime(initialTimestamp); - } - }) - .catch(() => { - if (scene.paths.screenshot) player.poster(scene.paths.screenshot); - }); + useEffect(() => { + function onplay(this: VideoJsPlayer) { + this.persistVolume().enabled = true; + if (scene?.interactive && interactiveReady.current) { + interactiveClient.play(this.currentTime()); + } } - }, [scene, initialTimestamp]); + + 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 player = playerRef.current; + if (!player) return; + + 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; - } - } - } - } + const { duration } = file; + const sourceSelector = player.sourceSelector(); + sourceSelector.setSources( + scene.sceneStreams.map((stream) => { + const isDirect = + stream.url.endsWith("/stream") || stream.url.endsWith("/stream.m3u8"); - function handleOffset(player: VideoJsPlayer) { - if (!scene || !file) return; + return { + src: stream.url, + type: stream.mime_type ?? undefined, + label: stream.label ?? undefined, + offset: !isDirect, + duration, + }; + }) + ); - const currentSrc = new URL(player.currentSrc()); - - const isDirect = - currentSrc.pathname.endsWith("/stream") || - currentSrc.pathname.endsWith("/stream.m3u8"); - - const curTime = player.currentTime(); - if (!isDirect) { - (player as any).setOffsetDuration(file.duration); - } else { - (player as any).clearOffsetDuration(); - } - - 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 = ({ return languageCode; } - function loadCaptions(player: VideoJsPlayer) { - if (!scene) return; + if (scene.captions && scene.captions.length > 0) { + const languageCode = getDefaultLanguageCode(); + let hasDefault = false; - if (scene.captions) { - var languageCode = getDefaultLanguageCode(); - var hasDefault = false; - - for (let caption of scene.captions) { - var lang = caption.language_code; - var label = lang; - if (languageMap.has(lang)) { - label = languageMap.get(lang)!; - } - - label = label + " (" + caption.caption_type + ")"; - var setAsDefault = !hasDefault && languageCode == lang; - if (!hasDefault && setAsDefault) { - hasDefault = true; - } - player.addRemoteTextTrack( - { - src: - scene.paths.caption + - "?lang=" + - lang + - "&type=" + - caption.caption_type, - kind: "captions", - srclang: lang, - label: label, - default: setAsDefault, - }, - true - ); + for (let caption of scene.captions) { + const lang = caption.language_code; + let label = lang; + if (languageMap.has(lang)) { + label = languageMap.get(lang)!; } + + label = label + " (" + caption.caption_type + ")"; + const setAsDefault = !hasDefault && languageCode == lang; + if (setAsDefault) { + hasDefault = true; + } + sourceSelector.addTextTrack( + { + src: `${scene.paths.caption}?lang=${lang}&type=${caption.caption_type}`, + kind: "captions", + srclang: lang, + label: label, + default: setAsDefault, + }, + false + ); } } - function loadstart(this: VideoJsPlayer) { - // handle offset after loading so that we get the correct current source - handleOffset(this); + if (scene.paths.screenshot) { + player.poster(scene.paths.screenshot); + } else { + player.poster(""); } - function onPlay(this: VideoJsPlayer) { - this.poster(""); - if (scene?.interactive && interactiveReady.current) { - interactiveClient.play(this.currentTime()); - } - } + auto.current = + autoplay || (config?.autostartVideo ?? false) || _initialTimestamp > 0; - function pause() { + initialTimestamp.current = _initialTimestamp; + setTime(_initialTimestamp); + + 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, - }, - }); - } - - // 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, - ]); + player.play(); + }); + 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 = () => { - playerRef.current?.pause(); - }; - const onScrubberSeek = (seconds: number) => { - const player = playerRef.current; - if (player) { - player.play()?.then(() => { - player.currentTime(seconds); - }); + const onScrubberScroll = () => { + if (started.current) { + playerRef.current?.pause(); } }; + 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 ( -
+
diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index 7f81c3b9b..fd992f03f 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -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(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 newSpriteItems; + return sprites; } -export const ScenePlayerScrubber: React.FC = ( - props: IScenePlayerScrubberProps -) => { +export const ScenePlayerScrubber: React.FC = ({ + file, + scene, + time, + onSeek, + onScroll, +}) => { const contentEl = useRef(null); - const positionIndicatorEl = useRef(null); - const scrubberSliderEl = useRef(null); + const indicatorEl = useRef(null); + const sliderEl = useRef(null); const mouseDown = useRef(false); const lastMouseEvent = useRef(null); const startMouseEvent = useRef(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([]); + const [spriteItems, setSpriteItems] = useState(); 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); + 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); }); - }, [props.scene]); + }, [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); + 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); + } }; - }); - useEffect(() => { - if (!contentEl.current) { - return; - } - const el = contentEl.current; - el.addEventListener("mousedown", onMouseDown, false); + const content = contentEl.current!; + const resizeObserver = new ResizeObserver(onResize); + resizeObserver.observe(content); + return () => { - if (!el) { - return; - } - el.removeEventListener("mousedown", onMouseDown); + resizeObserver.unobserve(content); }; - }); + }, []); - useEffect(() => { - if (!contentEl.current) { - return; - } - const el = contentEl.current; - el.addEventListener("mousemove", onMouseMove, false); - return () => { - if (!el) { - return; - } - el.removeEventListener("mousemove", onMouseMove); - }; - }); - - function onMouseUp(this: Window, event: MouseEvent) { - if (!startMouseEvent.current || !scrubberSliderEl.current) { - return; - } - mouseDown.current = false; - 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); - } - - const markerIdString = target.getAttribute("data-marker-id"); - if (markerIdString != null) { - const marker = props.scene.scene_markers[Number(markerIdString)]; - seekSeconds = marker.seconds; - } - - if (seekSeconds) { - props.onSeek(seekSeconds); - } - } else if (Math.abs(velocity.current) > 25) { - const newPosition = getPosition() + velocity.current * 10; - setPosition(newPosition); - velocity.current = 0; - } + function setLinearTransition() { + const slider = sliderEl.current!; + slider.style.transition = "500ms linear"; } - function onMouseDown(this: HTMLDivElement, event: MouseEvent) { + 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 (!scrubWidth || !width) 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; + + 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; + + if (target.hasAttribute("data-sprite-item-id")) { + newPosition = midpointOffset - (target.offsetLeft + event.offsetX); + } + + if (target.hasAttribute("data-marker-id")) { + newPosition = midpointOffset - target.offsetLeft; + } + } + if (Math.abs(velocity.current) > 25) { + newPosition = position.current + velocity.current * 10; + velocity.current = 0; + } + + 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; - // negative dragging right (past), positive left (future) - const delta = event.clientX - (lastMouseEvent.current?.clientX ?? 0); + if (lastMouseEvent.current === startMouseEvent.current) { + onScroll(); + } - const movement = event.movementX; - velocity.current = movement; + // negative dragging right (past), positive left (future) + const delta = event.clientX - lastMouseEvent.current!.clientX; - const newPostion = getPosition() + delta; - setPosition(newPostion); - lastMouseEvent.current = event; - } + const movement = event.movementX; + velocity.current = movement; - function getBounds(): number { - if (!scrubberSliderEl.current || !positionIndicatorEl.current) { - return 0; - } - return ( - scrubberSliderEl.current.scrollWidth - - scrubberSliderEl.current.clientWidth - ); - } + clearTransition(); + setPosition(position.current + delta, false); + lastMouseEvent.current = event; + }, + [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 (
{marker.title || marker.primary_tag.name}
@@ -318,38 +319,17 @@ export const ScenePlayerScrubber: React.FC = ( } 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 (
- - {TextUtils.secondsToTimestamp(spriteItem.start)} -{" "} - {TextUtils.secondsToTimestamp(spriteItem.end)} - + {sprite.time}
); }); @@ -367,10 +347,10 @@ export const ScenePlayerScrubber: React.FC = (
-
+
-
+
{renderTags()}
{renderSprites()}
diff --git a/ui/v2.5/src/components/ScenePlayer/big-buttons.ts b/ui/v2.5/src/components/ScenePlayer/big-buttons.ts index ac503afee..619349aea 100644 --- a/ui/v2.5/src/components/ScenePlayer/big-buttons.ts +++ b/ui/v2.5/src/components/ScenePlayer/big-buttons.ts @@ -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; diff --git a/ui/v2.5/src/components/ScenePlayer/live.ts b/ui/v2.5/src/components/ScenePlayer/live.ts index aae1e81d7..e55456cb9 100644 --- a/ui/v2.5/src/components/ScenePlayer/live.ts +++ b/ui/v2.5/src/components/ScenePlayer/live.ts @@ -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; +export interface ISource extends videojs.Tech.SourceObject { + offset?: boolean; + duration?: number; +} - 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, - }; +interface ICue extends TextTrackCue { + _startTime?: number; + _endTime?: number; +} - Player.prototype.clearOffsetDuration = function () { - this._offsetDuration = undefined; - this._offsetStart = undefined; - }; +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; - Player.prototype.setOffsetDuration = function (duration: number) { - this._offsetDuration = duration; - }; + 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; + } + } - Player.prototype.duration = function () { - if (this._offsetDuration !== undefined) { - return this._offsetDuration; - } - return Player.__super__.duration.apply(this, arguments); - }; + function updateOffsetStart(offset: number | undefined) { + offsetStart = offset; - Player.prototype.currentTime = function (seconds: number) { - if (seconds !== undefined && this._offsetDuration !== undefined) { - this._offsetStart = seconds; + if (!tech) return; + offset = offset ?? 0; - 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, - }; + 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; } - ); - this.src(newSources); - this.play(); + cue.startTime = cue._startTime - offset; + cue.endTime = cue._endTime - offset; + } + } + } + } + return { + 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(); - }; - } -}; -// Register the plugin with video.js. -videojs.registerPlugin("offset", offset); + const timeRanges: number[][] = []; + for (let i = 0; i < buffers.length; i++) { + const start = buffers.start(i) + offsetStart; + const end = buffers.end(i) + offsetStart; -export default offset; + 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; + } + }, + }; +} + +videojs.use("*", offsetMiddleware); diff --git a/ui/v2.5/src/components/ScenePlayer/markers.ts b/ui/v2.5/src/components/ScenePlayer/markers.ts index d61ed9b0d..07a9cd9df 100644 --- a/ui/v2.5/src/components/ScenePlayer/markers.ts +++ b/ui/v2.5/src/components/ScenePlayer/markers.ts @@ -1,107 +1,142 @@ import videojs, { VideoJsPlayer } from "video.js"; -const markers = function (this: VideoJsPlayer) { - const player = this; +interface IMarker { + title: string; + time: number; +} - function getPosition(marker: VTTCue) { - return (marker.startTime / player.duration()) * 100; - } +interface IMarkersOptions { + markers?: IMarker[]; +} - function createMarkerToolTip() { - const tooltip = videojs.dom.createEl("div") as HTMLElement; - tooltip.className = "vjs-marker-tooltip"; +class MarkersPlugin extends videojs.getPlugin("plugin") { + private markers: IMarker[] = []; + private markerDivs: HTMLDivElement[] = []; + private markerTooltip: HTMLElement | null = null; + private defaultTooltip: HTMLElement | null = null; - return tooltip; - } + constructor(player: VideoJsPlayer, options?: IMarkersOptions) { + super(player); - 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; + player.ready(() => { + // create marker tooltip + const tooltip = videojs.dom.createEl("div") as HTMLElement; + tooltip.className = "vjs-marker-tooltip"; + 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); - - // hide default tooltip - const defaultTooltip = parent?.querySelector( - ".vjs-time-tooltip" - ) as HTMLElement; - defaultTooltip.style.visibility = "hidden"; - }); - - markerDiv.addEventListener("mouseout", function () { - removeMarkerToolTip(); - - // show default tooltip - const defaultTooltip = player + // save default tooltip + this.defaultTooltip = player .el() - .querySelector( + .querySelector( ".vjs-progress-holder .vjs-mouse-display .vjs-time-tooltip" - ) as HTMLElement; - if (defaultTooltip) defaultTooltip.style.visibility = "visible"; + ); + + options?.markers?.forEach(this.addMarker, this); }); - return markerDiv; - } + player.on("loadedmetadata", () => { + const seekBar = player.el().querySelector(".vjs-progress-holder"); + const duration = this.player.duration(); - function removeMarkerDivs() { - const divs = player - .el() - .querySelectorAll(".vjs-progress-holder .vjs-marker"); - divs.forEach((div) => { - div.remove(); - }); - } + for (let i = 0; i < this.markers.length; i++) { + const marker = this.markers[i]; + const markerDiv = this.markerDivs[i]; - this.on("loadedmetadata", function () { - removeMarkerDivs(); - removeMarkerToolTip(); - - 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); + 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 + if (this.defaultTooltip) this.defaultTooltip.style.visibility = "hidden"; + } + + private hideMarkerTooltip() { + if (this.markerTooltip) this.markerTooltip.style.visibility = "hidden"; + + // show default tooltip + if (this.defaultTooltip) this.defaultTooltip.style.visibility = "visible"; + } + + 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(); + } + + removeMarkers(markers: IMarker[]) { + markers.forEach(this.removeMarker, this); + } + + 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; diff --git a/ui/v2.5/src/components/ScenePlayer/persist-volume.ts b/ui/v2.5/src/components/ScenePlayer/persist-volume.ts index bbfe0503a..6c36267db 100644 --- a/ui/v2.5/src/components/ScenePlayer/persist-volume.ts +++ b/ui/v2.5/src/components/ScenePlayer/persist-volume.ts @@ -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"; +const levelKey = "volume-level"; +const mutedKey = "volume-muted"; - player.on("volumechange", function () { - localForage.setItem(levelKey, player.volume()); - localForage.setItem(mutedKey, player.muted()); - }); +interface IPersistVolumeOptions { + enabled?: boolean; +} - localForage.getItem(levelKey).then((value) => { - if (value !== null) { - player.volume(value as number); - } - }); +class PersistVolumePlugin extends videojs.getPlugin("plugin") { + enabled: boolean; - localForage.getItem(mutedKey).then((value) => { - if (value !== null) { - player.muted(value as 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()); + } + }); + + player.ready(() => { + this.ready(); + }); + } + + private ready() { + localForage.getItem(levelKey).then((value) => { + if (value !== null) { + this.player.volume(value); + } + }); + + localForage.getItem(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; diff --git a/ui/v2.5/src/components/ScenePlayer/source-selector.ts b/ui/v2.5/src/components/ScenePlayer/source-selector.ts index 772f21ad4..206d3fbca 100644 --- a/ui/v2.5/src/components/ScenePlayer/source-selector.ts +++ b/ui/v2.5/src/components/ScenePlayer/source-selector.ts @@ -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; - } - - // populate source sortIndex first if not present - const currentSources = (player.currentSources() as ISource[]).map( - (src, i) => { - return { - ...src, - sortIndex: src.sortIndex ?? i, - selected: false, - }; - } - ); - - // 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); - - const currentTime = player.currentTime(); - - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - (player as any).clearOffsetDuration(); - player.src(currentSources); - 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; - }); + return this.items; } } -const sourceSelector = function (this: VideoJsPlayer) { - const player = this; +class SourceSelectorPlugin extends videojs.getPlugin("plugin") { + private menu: SourceMenuButton; + private sources: ISource[] = []; + private selectedIndex = -1; + private cleanupTextTracks: HTMLTrackElement[] = []; + private manualTextTracks: HTMLTrackElement[] = []; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const PlayerConstructor = this.constructor as any; - if (!PlayerConstructor.__sourceSelector) { - PlayerConstructor.__sourceSelector = { - selectSource: PlayerConstructor.prototype.selectSource, - }; + 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(); + + // 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(); + }); + + player.on("ready", () => { + const { controlBar } = player; + const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); + 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"); + } + }); } - videojs.registerComponent("SourceMenuButton", SourceMenuButton); + setSources(sources: ISource[]) { + const cleanupTracks = this.cleanupTextTracks.splice(0); + for (const track of cleanupTracks) { + this.player.removeRemoteTextTrack(track); + } - player.on("loadedmetadata", function () { - const { controlBar } = player; - const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); + this.menu.setSources(sources); + if (sources.length !== 0) { + this.selectedIndex = 0; + } else { + this.selectedIndex = -1; + } - const existingMenuButton = controlBar.getChild("SourceMenuButton"); - if (existingMenuButton) controlBar.removeChild(existingMenuButton); + this.sources = sources; + this.player.src(this.sources); + } - const menuButton = controlBar.addChild("SourceMenuButton"); + get textTracks(): HTMLTrackElement[] { + return [...this.cleanupTextTracks, ...this.manualTextTracks]; + } - controlBar.el().insertBefore(menuButton.el(), fullscreenToggle); - }); -}; + 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; diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index d04f3a7f5..f71443635 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -12,30 +12,24 @@ $sceneTabWidth: 450px; height: 100vh; } - .video-js { - height: 56.25vw; - overflow: hidden; - width: 100%; - - @media (min-width: 1200px) { - height: 100%; - } - } - &.portrait .video-js { height: 177.78vw; } +} + +.video-js { + height: 56.25vw; + overflow: hidden; + width: 100%; + + @media (min-width: 1200px) { + height: 100%; + } .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); -} diff --git a/ui/v2.5/src/components/ScenePlayer/util.ts b/ui/v2.5/src/components/ScenePlayer/util.ts index c1d595b49..08417289b 100644 --- a/ui/v2.5/src/components/ScenePlayer/util.ts +++ b/ui/v2.5/src/components/ScenePlayer/util.ts @@ -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(); diff --git a/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts b/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts new file mode 100644 index 000000000..33495eec7 --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts @@ -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 { + 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; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 2bab5fc95..710ff64b3 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -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 = ({ onQueueNext, onQueuePrevious, onQueueRandom, + onDelete, continuePlaylist, - playScene, + loadScene, queueHasMoreScenes, onQueueMoreScenes, onQueueLessScenes, @@ -89,7 +98,6 @@ const ScenePage: React.FC = ({ setCollapsed, setContinuePlaylist, }) => { - const history = useHistory(); const Toast = useToast(); const intl = useIntl(); const [updateScene] = useSceneUpdate(); @@ -216,7 +224,7 @@ const ScenePage: React.FC = ({ function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { - history.push("/scenes"); + onDelete(); } } @@ -400,14 +408,14 @@ const ScenePage: React.FC = ({ 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} /> @@ -441,7 +449,7 @@ const ScenePage: React.FC = ({ isVisible={activeTabKey === "scene-edit-panel"} scene={scene} onDelete={() => setIsDeleteAlertOpen(true)} - onUpdate={() => refetch()} + onUpdate={refetch} /> @@ -468,7 +476,7 @@ const ScenePage: React.FC = ({ >
{scene.studio && ( -

+

= ({ {renderTabs()}

-
@@ -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(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([]); + 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 ; - if (!loading && !data?.findScene) - return ; - */ + 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 = () => { ) : (
)} -
+
= 0 && currentQueueIndex < queueScenes.length - 1 - ? onQueueNext - : undefined - } - onPrevious={currentQueueIndex > 0 ? onQueuePrevious : undefined} + onNext={onNext} + onPrevious={onPrevious} />
diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 7fe17d46a..57dae2d7b 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 }); } } } diff --git a/ui/v2.5/src/models/react-jw-player.d.ts b/ui/v2.5/src/models/react-jw-player.d.ts deleted file mode 100644 index db9b06cb2..000000000 --- a/ui/v2.5/src/models/react-jw-player.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module "react-jw-player" { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ReactJSPlayer: any; - export default ReactJSPlayer; -} diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index 199218e33..87781e2bd 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -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) { 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) { - const params = [ - this.makeQueryParameters(options?.sceneIndex, options?.newPage), - options?.autoPlay ? "autoplay=true" : "", - options?.continue ? "continue=true" : "", - ].filter((param) => !!param); + 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); + } return `/scenes/${sceneID}${params.length ? "?" + params.join("&") : ""}`; } } diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 6bbd64791..fa5ab66aa 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -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"