Merge pull request #185 from frost768/feat/subtitle-rendering

Subtitle rendering feature added
This commit is contained in:
mrjvs 2023-03-19 20:25:23 +01:00 committed by GitHub
commit cf2060bd32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 673 additions and 101 deletions

View File

@ -9,6 +9,7 @@
"@react-spring/web": "^9.7.1",
"@use-gesture/react": "^10.2.24",
"crypto-js": "^4.1.1",
"dompurify": "^3.0.1",
"fscreen": "^1.2.0",
"fuse.js": "^6.4.6",
"hls.js": "^1.0.7",
@ -17,6 +18,7 @@
"json5": "^2.2.0",
"lodash.throttle": "^4.1.1",
"nanoid": "^4.0.0",
"node-webvtt": "^1.9.4",
"ofetch": "^1.0.0",
"pako": "^2.1.0",
"react": "^17.0.2",
@ -56,6 +58,7 @@
"@tailwindcss/line-clamp": "^0.4.2",
"@types/chromecast-caf-sender": "^1.0.5",
"@types/crypto-js": "^4.1.1",
"@types/dompurify": "^2.4.0",
"@types/fscreen": "^1.0.1",
"@types/lodash.throttle": "^4.1.7",
"@types/node": "^17.0.15",

27
src/@types/node_webtt.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
declare module "node-webvtt" {
interface Cue {
identifier: string;
start: number;
end: number;
text: string;
styles: string;
}
interface Options {
meta?: boolean;
strict?: boolean;
}
type ParserError = Error;
interface ParseResult {
valid: boolean;
strict: boolean;
cues: Cue[];
errors: ParserError[];
meta?: Map<string, string>;
}
interface Segment {
duration: number;
cues: Cue[];
}
function parse(text: string, options: Options): ParseResult;
function segment(input: string, segmentLength?: number): Segment[];
}

View File

@ -1,7 +1,9 @@
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import toWebVTT from "srt-webvtt";
import DOMPurify from "dompurify";
export const sanitize = DOMPurify.sanitize;
export const CUSTOM_CAPTION_ID = "customCaption";
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.type === MWCaptionType.SRT) {

View File

@ -18,15 +18,14 @@ interface FLIXMediaBase {
title: string;
url: string;
image: string;
type: "Movie" | "TV Series";
}
interface FLIXTVSerie extends FLIXMediaBase {
type: "TV Series";
seasons: number | null;
}
interface FLIXMovie extends FLIXMediaBase {
type: "Movie";
releaseDate: string;
}
@ -66,22 +65,28 @@ registerProvider({
baseURL: flixHqBase,
}
);
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
if (media.meta.type === MWMediaType.MOVIE) {
if (v.type !== "Movie") return false;
const movie = v as FLIXMovie;
return (
compareTitle(movie.title, media.meta.title) &&
movie.releaseDate === media.meta.year
);
}
const serie = v as FLIXTVSerie;
if (serie.seasons && media.meta.seasons) {
return (
compareTitle(serie.title, media.meta.title) &&
serie.seasons === media.meta.seasons.length
);
if (media.meta.type === MWMediaType.SERIES) {
if (v.type !== "TV Series") return false;
const serie = v as FLIXTVSerie;
if (serie.seasons && media.meta.seasons) {
return (
compareTitle(serie.title, media.meta.title) &&
serie.seasons === media.meta.seasons.length
);
}
return false;
}
return compareTitle(serie.title, media.meta.title);
return false;
});
if (!foundItem) throw new Error("No watchable item found");
const flixId = foundItem.id;
@ -110,6 +115,7 @@ registerProvider({
)?.id;
}
if (!episodeId) throw new Error("No watchable item found");
const watchInfo = await proxiedFetch<any>("/watch", {
baseURL: flixHqBase,
params: {

View File

@ -39,6 +39,7 @@ export enum Icons {
GEAR = "gear",
WATCH_PARTY = "watch_party",
PICTURE_IN_PICTURE = "pictureInPicture",
CHECKMARK = "checkmark",
}
export interface IconProps {
@ -85,6 +86,7 @@ const iconList: Record<Icons, string> = {
gear: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>`,
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`,
pictureInPicture: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/></svg>`,
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
};
function ChromeCastButton() {

View File

@ -1,6 +1,7 @@
import { Redirect, Route, Switch } from "react-router-dom";
import { BookmarkContextProvider } from "@/state/bookmark";
import { WatchedContextProvider } from "@/state/watched";
import { SettingsProvider } from "@/state/settings";
import { NotFoundPage } from "@/views/notfound/NotFoundView";
import { MediaView } from "@/views/media/MediaView";
@ -17,46 +18,48 @@ import { TestView } from "@/views/developer/TestView";
function App() {
return (
<WatchedContextProvider>
<BookmarkContextProvider>
<BannerContextProvider>
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
<SettingsProvider>
<WatchedContextProvider>
<BookmarkContextProvider>
<BannerContextProvider>
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
{/* pages */}
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<Route
exact
path="/search/:type/:query?"
component={SearchView}
/>
{/* pages */}
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<Route
exact
path="/search/:type/:query?"
component={SearchView}
/>
{/* other */}
<Route exact path="/dev" component={DeveloperView} />
<Route exact path="/dev/test" component={TestView} />
<Route exact path="/dev/video" component={VideoTesterView} />
<Route
exact
path="/dev/providers"
component={ProviderTesterView}
/>
<Route exact path="/dev/embeds" component={EmbedTesterView} />
<Route path="*" component={NotFoundPage} />
</Switch>
</Layout>
</BannerContextProvider>
</BookmarkContextProvider>
</WatchedContextProvider>
{/* other */}
<Route exact path="/dev" component={DeveloperView} />
<Route exact path="/dev/test" component={TestView} />
<Route exact path="/dev/video" component={VideoTesterView} />
<Route
exact
path="/dev/providers"
component={ProviderTesterView}
/>
<Route exact path="/dev/embeds" component={EmbedTesterView} />
<Route path="*" component={NotFoundPage} />
</Switch>
</Layout>
</BannerContextProvider>
</BookmarkContextProvider>
</WatchedContextProvider>
</SettingsProvider>
);
}

View File

@ -54,3 +54,123 @@ body[data-no-select] {
.google-cast-button:not(.casting) google-cast-launcher {
@apply brightness-[500];
}
/*generated with Input range slider CSS style generator (version 20211225)
https://toughengineer.github.io/demo/slider-styler*/
:root {
--slider-height: 0.25rem;
--slider-border-radius: 1em;
--slider-progress-background: #8652bb;
}
input[type=range].styled-slider {
height: var(--slider-height);
-webkit-appearance: none;
appearance: none;
border-radius: var(--slider-border-radius);
background: #1C161B;
}
/*progress support*/
input[type=range].styled-slider.slider-progress {
--range: calc(var(--max) - var(--min));
--ratio: calc((var(--value) - var(--min)) / var(--range));
--sx: calc(0.5 * 1rem + var(--ratio) * (100% - 1rem));
}
/*webkit*/
input[type=range].styled-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 1rem;
height: 1rem;
border-radius: var(--slider-border-radius);
background: #FFFFFF;
border: none;
box-shadow: 0 0 2px #000000;
margin-top: calc(0.25em * 0.5 - 1rem * 0.5);
}
input[type=range].styled-slider::-webkit-slider-runnable-track {
height: var(--slider-height);
border: none;
box-shadow: none;
border-radius: var(--slider-border-radius);
margin-bottom: 1.1em;
}
input[type=range].styled-slider::-webkit-slider-thumb:hover {
background: #DCDCDC;
}
input[type=range].styled-slider.slider-progress::-webkit-slider-runnable-track {
background: linear-gradient(var(--slider-progress-background),var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
}
/*mozilla*/
input[type=range].styled-slider::-moz-range-thumb {
width: 1rem;
height: 1rem;
border-radius: var(--slider-border-radius);
background: #FFFFFF;
border: none;
box-shadow: 0 0 2px #000000;
}
input[type=range].styled-slider::-moz-range-track {
height: var(--slider-height);
border: none;
border-radius: var(--slider-border-radius);
background: #1C161B;
box-shadow: none;
}
input[type=range].styled-slider::-moz-range-thumb:hover {
background: #DCDCDC;
}
input[type=range].styled-slider.slider-progress::-moz-range-track {
background: linear-gradient(var(--slider-progress-background),var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
}
/*ms*/
input[type=range].styled-slider::-ms-fill-upper {
background: transparent;
border-color: transparent;
}
input[type=range].styled-slider::-ms-fill-lower {
background: transparent;
border-color: transparent;
}
input[type=range].styled-slider::-ms-thumb {
width: 1rem;
height: 1rem;
border-radius: var(--slider-border-radius);
background: #FFFFFF;
border: none;
box-shadow: 0 0 2px #000000;
margin-top: 0;
box-sizing: border-box;
}
input[type=range].styled-slider::-ms-track {
height: var(--slider-height);
border-radius: var(--slider-border-radius);
background: #1C161B;
border: none;
box-shadow: none;
box-sizing: border-box;
}
input[type=range].styled-slider::-ms-thumb:hover {
background: #DCDCDC;
}
input[type=range].styled-slider.slider-progress::-ms-fill-lower {
height: var(--slider-height);
border-radius: var(--slider-border-radius) 0 0 5px;
margin: -undefined 0 -undefined -undefined;
background: var(--slider-progress-background);
border: none;
border-right-width: 0;
}

View File

@ -69,6 +69,13 @@
"sources": "Sources",
"seasons": "Seasons",
"captions": "Captions",
"captionPreferences": {
"title": "Customize",
"delay": "Delay",
"fontSize": "Size",
"opacity": "Opacity",
"color": "Color"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "No captions",
"linkedCaptions": "Linked captions",
@ -84,7 +91,8 @@
"embeds": "Choose which video to view",
"seasons": "Choose which season you want to watch",
"episode": "Pick an episode",
"captions": "Choose a subtitle language"
"captions": "Choose a subtitle language",
"captionPreferences": "Make subtitles look how you want it"
}
},
"errors": {

View File

@ -0,0 +1,78 @@
import { useStore } from "@/utils/storage";
import { createContext, ReactNode, useContext, useMemo } from "react";
import { SettingsStore } from "./store";
import { MWSettingsData } from "./types";
interface MWSettingsDataSetters {
setLanguage(language: string): void;
setCaptionDelay(delay: number): void;
setCaptionColor(color: string): void;
setCaptionFontSize(size: number): void;
setCaptionBackgroundColor(backgroundColor: string): void;
}
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
export function SettingsProvider(props: { children: ReactNode }) {
function enforceRange(min: number, value: number, max: number) {
return Math.max(min, Math.min(value, max));
}
const [settings, setSettings] = useStore(SettingsStore);
const context: MWSettingsDataWrapper = useMemo(() => {
const settingsContext: MWSettingsDataWrapper = {
...settings,
setLanguage(language) {
setSettings((oldSettings) => {
return {
...oldSettings,
language,
};
});
},
setCaptionDelay(delay: number) {
setSettings((oldSettings) => {
const captionSettings = oldSettings.captionSettings;
captionSettings.delay = enforceRange(-10, delay, 10);
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionColor(color) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.color = color;
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionFontSize(size) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.fontSize = enforceRange(10, size, 60);
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionBackgroundColor(backgroundColor) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.backgroundColor = backgroundColor;
const newSettings = oldSettings;
return newSettings;
});
},
};
return settingsContext;
}, [settings, setSettings]);
return (
<SettingsContext.Provider value={context}>
{props.children}
</SettingsContext.Provider>
);
}
export function useSettings() {
return useContext(SettingsContext);
}
export default SettingsContext;

View File

@ -0,0 +1 @@
export * from "./context";

View File

@ -0,0 +1,22 @@
import { createVersionedStore } from "@/utils/storage";
import { MWSettingsData } from "./types";
export const SettingsStore = createVersionedStore<MWSettingsData>()
.setKey("mw-settings")
.addVersion({
version: 0,
create(): MWSettingsData {
return {
language: "en",
captionSettings: {
delay: 0,
style: {
color: "#ffffff",
fontSize: 25,
backgroundColor: "#00000096",
},
},
};
},
})
.build();

View File

@ -0,0 +1,21 @@
export interface CaptionStyleSettings {
color: string;
/**
* Range is [10, 30]
*/
fontSize: number;
backgroundColor: string;
}
export interface CaptionSettings {
/**
* Range is [-10, 10]s
*/
delay: number;
style: CaptionStyleSettings;
}
export interface MWSettingsData {
language: string;
captionSettings: CaptionSettings;
}

View File

@ -27,9 +27,10 @@ import { ReactNode, useCallback, useState } from "react";
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
import { SettingsAction } from "./actions/SettingsAction";
import { DividerAction } from "./actions/DividerAction";
import { PictureInPictureAction } from "./actions/PictureInPictureAction";
type Props = VideoPlayerBaseProps;
@ -165,6 +166,7 @@ export function VideoPlayer(props: Props) {
</Transition>
{show ? <PopoutProviderAction /> : null}
</BackdropAction>
<CaptionRendererAction isControlsShown={show} />
{props.children}
</VideoPlayerError>
</>

View File

@ -0,0 +1,92 @@
import { Transition } from "@/components/Transition";
import { useSettings } from "@/state/settings";
import { sanitize } from "@/backend/helpers/captions";
import { parse, Cue } from "node-webvtt";
import { useRef } from "react";
import { useAsync } from "react-use";
import { useVideoPlayerDescriptor } from "../../state/hooks";
import { useProgress } from "../../state/logic/progress";
import { useSource } from "../../state/logic/source";
function CaptionCue({ text }: { text?: string }) {
const { captionSettings } = useSettings();
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
// added a <br /> for newlines
const html = sanitize(textWithNewlines, {
ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"],
ADD_TAGS: ["v", "lang"],
ALLOWED_ATTR: ["title", "lang"],
});
return (
<p
className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
style={{
...captionSettings.style,
}}
>
<span
// its sanitised a few lines up
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: html,
}}
dir="auto"
/>
</p>
);
}
export function CaptionRendererAction({
isControlsShown,
}: {
isControlsShown: boolean;
}) {
const descriptor = useVideoPlayerDescriptor();
const source = useSource(descriptor).source;
const videoTime = useProgress(descriptor).time;
const { captionSettings } = useSettings();
const captions = useRef<Cue[]>([]);
useAsync(async () => {
const url = source?.caption?.url;
if (url) {
// Is there a better way?
const result = await fetch(url);
// Uses UTF-8 by default
const text = await result.text();
captions.current = parse(text, { strict: false }).cues;
} else {
captions.current = [];
}
}, [source?.caption?.url]);
if (!captions.current.length) return null;
const isVisible = (start: number, end: number): boolean => {
const delayedStart = start + captionSettings.delay;
const delayedEnd = end + captionSettings.delay;
return (
Math.max(0, delayedStart) <= videoTime &&
Math.max(0, delayedEnd) >= videoTime
);
};
return (
<Transition
className={[
"pointer-events-none absolute flex w-full flex-col items-center transition-[bottom]",
isControlsShown ? "bottom-24" : "bottom-12",
].join(" ")}
animation="slide-up"
show
>
{captions.current.map(
({ identifier, end, start, text }) =>
isVisible(start, end) && (
<CaptionCue key={identifier || `${start}-${end}`} text={text} />
)
)}
</Transition>
);
}

View File

@ -1,7 +1,6 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useMisc } from "@/video/state/logic/misc";
import { useSource } from "@/video/state/logic/source";
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider";
import { useEffect, useMemo, useRef } from "react";
@ -13,7 +12,6 @@ interface Props {
function VideoElement(props: Props) {
const descriptor = useVideoPlayerDescriptor();
const mediaPlaying = useMediaPlaying(descriptor);
const source = useSource(descriptor);
const misc = useMisc(descriptor);
const ref = useRef<HTMLVideoElement>(null);
@ -44,11 +42,7 @@ function VideoElement(props: Props) {
muted={mediaPlaying.volume === 0}
playsInline
className="h-full w-full"
>
{source.source?.caption ? (
<track default kind="captions" src={source.source.caption.url} />
) : null}
</video>
/>
);
}

View File

@ -70,7 +70,6 @@ export function CaptionSelectionPopout(props: {
const captionFile = e.target.files[0];
setCustomCaption(captionFile);
}
return (
<FloatingView
{...props.router.pageProps(props.prefix)}
@ -81,6 +80,18 @@ export function CaptionSelectionPopout(props: {
title={t("videoPlayer.popouts.captions")}
description={t("videoPlayer.popouts.descriptions.captions")}
goBack={() => props.router.navigate("/")}
action={
<button
type="button"
onClick={() =>
props.router.navigate(`${props.prefix}/caption-settings`)
}
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<span>{t("videoPlayer.popouts.captionPreferences.title")}</span>
<Icon icon={Icons.GEAR} />
</button>
}
/>
<FloatingCardView.Content noSection>
<PopoutSection>

View File

@ -0,0 +1,150 @@
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingView } from "@/components/popout/FloatingView";
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { useSettings } from "@/state/settings";
import { useTranslation } from "react-i18next";
import { ChangeEventHandler, useEffect, useRef } from "react";
import { Icon, Icons } from "@/components/Icon";
export type SliderProps = {
label: string;
min: number;
max: number;
step: number;
value: number;
valueDisplay?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
};
export function Slider(props: SliderProps) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
const e = ref.current as HTMLInputElement;
e.style.setProperty("--value", e.value);
e.style.setProperty("--min", e.min === "" ? "0" : e.min);
e.style.setProperty("--max", e.max === "" ? "100" : e.max);
e.addEventListener("input", () => e.style.setProperty("--value", e.value));
}, [ref]);
return (
<div className="mb-6 flex flex-row gap-4">
<div className="flex w-full flex-col gap-2">
<label className="font-bold">{props.label}</label>
<input
type="range"
ref={ref}
className="styled-slider slider-progress"
onChange={props.onChange}
value={props.value}
max={props.max}
min={props.min}
step={props.step}
/>
</div>
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1">
<div className="text-center font-bold text-white">
{props.valueDisplay ?? props.value}
</div>
</div>
</div>
);
}
export function CaptionSettingsPopout(props: {
router: ReturnType<typeof useFloatingRouter>;
prefix: string;
}) {
// For now, won't add label texts to language files since options are prone to change
const { t } = useTranslation();
const {
captionSettings,
setCaptionBackgroundColor,
setCaptionColor,
setCaptionDelay,
setCaptionFontSize,
} = useSettings();
const colors = ["#ffffff", "#00ffff", "#ffff00"];
return (
<FloatingView {...props.router.pageProps(props.prefix)} width={375}>
<FloatingCardView.Header
title={t("videoPlayer.popouts.captionPreferences.title")}
description={t("videoPlayer.popouts.descriptions.captionPreferences")}
goBack={() => props.router.navigate("/captions")}
/>
<FloatingCardView.Content>
<Slider
label={t("videoPlayer.popouts.captionPreferences.delay")}
max={10}
min={-10}
step={0.1}
valueDisplay={`${captionSettings.delay.toFixed(1)}s`}
value={captionSettings.delay}
onChange={(e) => setCaptionDelay(e.target.valueAsNumber)}
/>
<Slider
label="Size"
min={14}
step={1}
max={60}
value={captionSettings.style.fontSize}
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
/>
<Slider
label={t("videoPlayer.popouts.captionPreferences.opacity")}
step={1}
min={0}
max={255}
valueDisplay={`${(
(parseInt(
captionSettings.style.backgroundColor.substring(7, 9),
16
) /
255) *
100
).toFixed(0)}%`}
value={parseInt(
captionSettings.style.backgroundColor.substring(7, 9),
16
)}
onChange={(e) =>
setCaptionBackgroundColor(
`${captionSettings.style.backgroundColor.substring(
0,
7
)}${e.target.valueAsNumber.toString(16)}`
)
}
/>
<div className="flex flex-row justify-between">
<label className="font-bold" htmlFor="color">
{t("videoPlayer.popouts.captionPreferences.color")}
</label>
<div className="flex flex-row gap-2">
{colors.map((color) => (
<div
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${
color === captionSettings.style.color ? "bg-[#1C161B]" : ""
}`}
onClick={() => setCaptionColor(color)}
>
<div
className="h-4 w-4 cursor-pointer appearance-none rounded-full"
style={{
backgroundColor: color,
}}
/>
<Icon
className={[
"absolute text-xs text-[#1C161B]",
color === captionSettings.style.color ? "" : "hidden",
].join(" ")}
icon={Icons.CHECKMARK}
/>
</div>
))}
</div>
</div>
</FloatingCardView.Content>
</FloatingView>
);
}

View File

@ -7,6 +7,7 @@ import { CaptionsSelectionAction } from "@/video/components/actions/list-entries
import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction";
import { CaptionSelectionPopout } from "./CaptionSelectionPopout";
import { SourceSelectionPopout } from "./SourceSelectionPopout";
import { CaptionSettingsPopout } from "./CaptionSettingsPopout";
export function SettingsPopout() {
const floatingRouter = useFloatingRouter();
@ -24,6 +25,10 @@ export function SettingsPopout() {
</FloatingView>
<SourceSelectionPopout router={floatingRouter} prefix="source" />
<CaptionSelectionPopout router={floatingRouter} prefix="captions" />
<CaptionSettingsPopout
router={floatingRouter}
prefix="caption-settings"
/>
</>
);
}

View File

@ -16,6 +16,7 @@
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./src",
"typeRoots": ["./src/@types"],
"paths": {
"@/*": ["./*"]
},

112
yarn.lock
View File

@ -944,7 +944,7 @@
"@esbuild/darwin-arm64@0.16.5":
version "0.16.5"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.5.tgz"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.16.5.tgz#6e553f4be193d25a5e7cb6a73077d407a64bd6ad"
integrity sha512-4HlbUMy50cRaHGVriBjShs46WRPshtnVOqkxEGhEuDuJhgZ3regpWzaQxXOcDXFvVwue8RiqDAAcOi/QlVLE6Q==
"@esbuild/darwin-x64@0.16.5":
@ -1034,7 +1034,7 @@
"@esbuild/win32-x64@0.16.5":
version "0.16.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.16.5.tgz#9398d079a83b309b44021634ae6b4f7bc6a0cad0"
resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.16.5.tgz"
integrity sha512-q00Jasz6/wCOD2XxRj4GEwj27u1zfpiBniL1ip3/YGGcYtvOoGKCNSS47sufO/8ixEgrSYDlkglSd6CxcS7m0g==
"@eslint/eslintrc@^1.3.3":
@ -1053,9 +1053,9 @@
strip-json-comments "^3.1.1"
"@formkit/auto-animate@^1.0.0-beta.5":
version "1.0.0-beta.6"
resolved "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-1.0.0-beta.6.tgz"
integrity sha512-PVDhLAlr+B4Xb7e+1wozBUWmXa6BFU8xUPR/W/E+TsQhPS1qkAdAsJ25keEnFrcePSnXHrOsh3tiFbEToOzV9w==
version "1.0.0-beta.5"
resolved "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-1.0.0-beta.5.tgz"
integrity sha512-WoSwyhAZPOe6RB/IgicOtCHtrWwEpfKIZ/H/nxpKfnZL9CB6hhhBGU5bCdMRw7YpAUF2CDlQa+WWh+gCqz5lDg==
"@headlessui/react@^1.5.0":
version "1.7.5"
@ -1264,7 +1264,7 @@
"@swc/core-darwin-arm64@1.3.22":
version "1.3.22"
resolved "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.22.tgz"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.22.tgz#296db00b567d7fab0fc438eccc7334b53d54e2f2"
integrity sha512-MMhtPsuXp8gpUgr9bs+RZQ2IyFGiUNDG93usCDAFgAF+6VVp+YaAVjET/3/Bx5Lk2WAt0RxT62C9KTEw1YMo3w==
"@swc/core-darwin-x64@1.3.22":
@ -1309,7 +1309,7 @@
"@swc/core-win32-x64-msvc@1.3.22":
version "1.3.22"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.22.tgz#fb820b1aa03605363d141c9656d966a25000790f"
resolved "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.22.tgz"
integrity sha512-ESyn4lZXAKEE3mcTaDfXatsolCiEfVGstsXdgBmZYa6o1IE1bDW8FE7Ob/Y+82WTpm9+A9ZYXYjZ62t67POHZg==
"@swc/core@^1.3.21":
@ -1351,9 +1351,9 @@
integrity sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==
"@types/chrome@*":
version "0.0.217"
resolved "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.217.tgz"
integrity sha512-q8fLzCCoHiR9gYRoqvrx12+HaJjRTqUom5Ks/wLSR8Ac83qAqWaA4NgUBUcDjM1O1ACczygxIHCEENXs1zmbqQ==
version "0.0.210"
resolved "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.210.tgz"
integrity sha512-VSjQu1k6a/rAfuqR1Gi/oxHZj4+t6+LG+GobNI3ZWI6DQ+fmphNSF6TrLHG6BYK2bXc9Gb4c1uXFKRRVLaGl5Q==
dependencies:
"@types/filesystem" "*"
"@types/har-format" "*"
@ -1370,6 +1370,13 @@
resolved "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz"
integrity sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==
"@types/dompurify@^2.4.0":
version "2.4.0"
resolved "https://registry.npmjs.org/@types/dompurify/-/dompurify-2.4.0.tgz"
integrity sha512-IDBwO5IZhrKvHFUl+clZxgf3hn2b/lU6H1KaBShPkQyGJUQ0xwebezIPSuiyGwfz1UzJWQl4M7BDxtHtCCPlTg==
dependencies:
"@types/trusted-types" "*"
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz"
@ -1520,7 +1527,7 @@
resolved "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz"
integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==
"@types/trusted-types@^2.0.2":
"@types/trusted-types@*", "@types/trusted-types@^2.0.2":
version "2.0.3"
resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz"
integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==
@ -1707,7 +1714,7 @@ acorn-walk@^8.0.2, acorn-walk@^8.2.0:
acorn@^7.0.0:
version "7.4.1"
resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
acorn@^8.1.0, acorn@^8.5.0, acorn@^8.8.0, acorn@^8.8.1, acorn@^8.8.2:
@ -1992,9 +1999,9 @@ camelcase-css@^2.0.1:
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449:
version "1.0.30001458"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001458.tgz"
integrity sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w==
version "1.0.30001457"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001457.tgz"
integrity sha512-SDIV6bgE1aVbK6XyxdURbUE89zY7+k1BBBaOwYwkNCglXlel/E7mELiHC64HQ+W0xSKlqWhV9Wh7iHxUjMs4fA==
chai@^4.3.7:
version "4.3.7"
@ -2100,6 +2107,11 @@ commander@^2.20.0:
resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@^7.1.0:
version "7.2.0"
resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
commander@^8.0.0:
version "8.3.0"
resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz"
@ -2133,9 +2145,9 @@ copy-to-clipboard@^3.3.1:
toggle-selection "^1.0.6"
core-js-compat@^3.25.1:
version "3.29.0"
resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.29.0.tgz"
integrity sha512-ScMn3uZNAFhK2DGoEfErguoiAHhV2Ju+oJo/jK08p7B3f3UhocUrCCkTvnZaiS+edl5nlIoiBXKcwMc6elv4KQ==
version "3.28.0"
resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.28.0.tgz"
integrity sha512-myzPgE7QodMg4nnd3K1TDoES/nADRStM8Gpz0D6nhkwbmwEnE0ZGJgoWsvQ722FR8D7xS0n0LV556RcEicjTyg==
dependencies:
browserslist "^4.21.5"
@ -2145,9 +2157,9 @@ core-js-pure@^3.25.1:
integrity sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ==
core-js@^3.6.5:
version "3.28.0"
resolved "https://registry.npmjs.org/core-js/-/core-js-3.28.0.tgz"
integrity sha512-GiZn9D4Z/rSYvTeg1ljAIsEqFm0LaN9gVtwDCrKL80zHtS31p9BAjmTxVqTQDMpwlMolJZOFntUG2uwyj7DAqw==
version "3.27.1"
resolved "https://registry.npmjs.org/core-js/-/core-js-3.27.1.tgz"
integrity sha512-GutwJLBChfGCpwwhbYoqfv03LAfmiz7e7D/BNxzeMxwQf10GRSzqiOjx7AmtEk+heiD/JWmBuyBPgFtx0Sg1ww==
cross-spawn@^7.0.2:
version "7.0.3"
@ -2285,7 +2297,7 @@ delayed-stream@~1.0.0:
resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
destr@^1.2.2:
destr@^1.2.1:
version "1.2.2"
resolved "https://registry.npmjs.org/destr/-/destr-1.2.2.tgz"
integrity sha512-lrbCJwD9saUQrqUfXvl6qoM+QN3W7tLV5pAOs+OqOmopCCz/JkE05MHedJR1xfk4IAnZuJXPVuN5+7jNA2ZCiA==
@ -2350,6 +2362,11 @@ domexception@^4.0.0:
dependencies:
webidl-conversions "^7.0.0"
dompurify@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.0.1.tgz"
integrity sha512-60tsgvPKwItxZZdfLmamp0MTcecCta3avOhsLgPZ0qcWt96OasFfhkeIRbJ6br5i0fQawT1/RBGB5L58/Jpwuw==
eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
@ -2872,7 +2889,7 @@ fs-extra@^9.0.1:
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fscreen@^1.2.0:
@ -2882,7 +2899,7 @@ fscreen@^1.2.0:
fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
function-bind@^1.1.1:
@ -2970,13 +2987,13 @@ glob@^7.1.3, glob@^7.1.6, glob@^7.2.0:
globals@^11.1.0:
version "11.12.0"
resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz"
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
globals@^13.15.0:
version "13.19.0"
resolved "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz"
integrity sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==
version "13.20.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-13.20.0.tgz#ea276a1e508ffd4f1612888f9d1bad1e2717bf82"
integrity sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==
dependencies:
type-fest "^0.20.2"
@ -3156,7 +3173,7 @@ imurmurhash@^0.1.4:
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
dependencies:
once "^1.3.0"
@ -3164,7 +3181,7 @@ inflight@^1.0.4:
inherits@2:
version "2.0.4"
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
inline-style-prefixer@^6.0.0:
@ -3707,16 +3724,23 @@ natural-compare@^1.4.0:
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
node-fetch-native@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.0.2.tgz"
integrity sha512-KIkvH1jl6b3O7es/0ShyCgWLcfXxlBrLBbP3rOr23WArC66IMcU4DeZEeYEOwnopYhawLTn7/y+YtmASe8DFVQ==
node-fetch-native@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.0.1.tgz"
integrity sha512-VzW+TAk2wE4X9maiKMlT+GsPU4OMmR1U9CrHSmd3DFLn2IcZ9VJ6M6BBugGfYUnPCLSYxXdZy17M0BEJyhUTwg==
node-releases@^2.0.8:
version "2.0.10"
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz"
integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==
node-webvtt@^1.9.4:
version "1.9.4"
resolved "https://registry.npmjs.org/node-webvtt/-/node-webvtt-1.9.4.tgz"
integrity sha512-EjrJdKdxSyd8j4LMLW6s2Ah4yNoeVXp18Ob04CQl1In18xcUmKzEE8pcsxxnFVqanTyjbGYph2VnvtwIXR4EjA==
dependencies:
commander "^7.1.0"
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
@ -3805,17 +3829,17 @@ object.values@^1.1.5:
es-abstract "^1.20.4"
ofetch@^1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/ofetch/-/ofetch-1.0.1.tgz"
integrity sha512-icBz2JYfEpt+wZz1FRoGcrMigjNKjzvufE26m9+yUiacRQRHwnNlGRPiDnW4op7WX/MR6aniwS8xw8jyVelF2g==
version "1.0.0"
resolved "https://registry.npmjs.org/ofetch/-/ofetch-1.0.0.tgz"
integrity sha512-d40aof8czZFSQKJa4+F7Ch3UC5D631cK1TTUoK+iNEut9NoiCL+u0vykl/puYVUS2df4tIQl5upQcolIcEzQjQ==
dependencies:
destr "^1.2.2"
node-fetch-native "^1.0.2"
ufo "^1.1.0"
destr "^1.2.1"
node-fetch-native "^1.0.1"
ufo "^1.0.0"
once@^1.3.0:
version "1.4.0"
resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
dependencies:
wrappy "1"
@ -3891,7 +3915,7 @@ path-exists@^4.0.0:
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
path-key@^3.0.0, path-key@^3.1.0:
@ -4916,7 +4940,7 @@ typescript@^4.6.4:
resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz"
integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
ufo@^1.1.0:
ufo@^1.0.0, ufo@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/ufo/-/ufo-1.1.0.tgz"
integrity sha512-LQc2s/ZDMaCN3QLpa+uzHUOQ7SdV0qgv3VBXOolQGXTaaZpIur6PwUclF5nN2hNkiTRcUugXd1zFOW3FLJ135Q==
@ -5396,7 +5420,7 @@ workbox-window@6.5.4, workbox-window@^6.5.4:
wrappy@1:
version "1.0.2"
resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@^8.11.0: