diff --git a/package.json b/package.json index 9fdde671..0e9b69df 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "movie-web", - "version": "3.0.5", + "version": "3.0.6", "private": true, "homepage": "https://movie.squeezebox.dev", "dependencies": { "@formkit/auto-animate": "^1.0.0-beta.5", "@headlessui/react": "^1.5.0", - "@types/react-helmet": "^6.1.6", + "@react-spring/web": "^9.7.1", + "@use-gesture/react": "^10.2.24", "crypto-js": "^4.1.1", "fscreen": "^1.2.0", "fuse.js": "^6.4.6", @@ -90,6 +91,7 @@ "vite-plugin-package-version": "^1.0.2", "vite-plugin-pwa": "^0.14.4", "vitest": "^0.28.5", - "workbox-window": "^6.5.4" + "workbox-window": "^6.5.4", + "@types/react-helmet": "^6.1.6" } } diff --git a/src/backend/embeds/playm4u.ts b/src/backend/embeds/playm4u.ts index 8328d337..1e5c3ca4 100644 --- a/src/backend/embeds/playm4u.ts +++ b/src/backend/embeds/playm4u.ts @@ -10,6 +10,7 @@ registerEmbedScraper({ async getStream() { // throw new Error("Oh well 2") return { + embedId: "", streamUrl: "", quality: MWStreamQuality.Q1080P, captions: [], diff --git a/src/backend/embeds/streamm4u.ts b/src/backend/embeds/streamm4u.ts index d0eba66a..b28b7ab8 100644 --- a/src/backend/embeds/streamm4u.ts +++ b/src/backend/embeds/streamm4u.ts @@ -3,7 +3,7 @@ import { registerEmbedScraper } from "@/backend/helpers/register"; import { MWStreamQuality, MWStreamType, - MWStream, + MWEmbedStream, } from "@/backend/helpers/streams"; import { proxiedFetch } from "@/backend/helpers/fetch"; @@ -13,7 +13,7 @@ const URL_API = `${URL_BASE}/api`; const URL_API_SOURCE = `${URL_API}/source`; async function scrape(embed: string) { - const sources: MWStream[] = []; + const sources: MWEmbedStream[] = []; const embedID = embed.split("/").pop(); @@ -28,6 +28,7 @@ async function scrape(embed: string) { for (const stream of streams) { sources.push({ + embedId: "", streamUrl: stream.file as string, quality: stream.label as MWStreamQuality, type: stream.type as MWStreamType, diff --git a/src/backend/helpers/embed.ts b/src/backend/helpers/embed.ts index 64d039b7..0dec6422 100644 --- a/src/backend/helpers/embed.ts +++ b/src/backend/helpers/embed.ts @@ -1,4 +1,4 @@ -import { MWStream } from "./streams"; +import { MWEmbedStream } from "./streams"; export enum MWEmbedType { M4UFREE = "m4ufree", @@ -23,5 +23,5 @@ export type MWEmbedScraper = { rank: number; disabled?: boolean; - getStream(ctx: MWEmbedContext): Promise; + getStream(ctx: MWEmbedContext): Promise; }; diff --git a/src/backend/helpers/scrape.ts b/src/backend/helpers/scrape.ts index cb160305..70e20348 100644 --- a/src/backend/helpers/scrape.ts +++ b/src/backend/helpers/scrape.ts @@ -43,7 +43,13 @@ async function findBestEmbedStream( providerId: string, ctx: MWProviderRunContext ): Promise { - if (result.stream) return result.stream; + if (result.stream) { + return { + ...result.stream, + providerId, + embedId: providerId, + }; + } let embedNum = 0; for (const embed of result.embeds) { @@ -89,6 +95,7 @@ async function findBestEmbedStream( type: "embed", }); + stream.providerId = providerId; return stream; } diff --git a/src/backend/helpers/streams.ts b/src/backend/helpers/streams.ts index 92943d94..27e9fddb 100644 --- a/src/backend/helpers/streams.ts +++ b/src/backend/helpers/streams.ts @@ -10,6 +10,7 @@ export enum MWCaptionType { export enum MWStreamQuality { Q360P = "360p", + Q540P = "540p", Q480P = "480p", Q720P = "720p", Q1080P = "1080p", @@ -27,5 +28,11 @@ export type MWStream = { streamUrl: string; type: MWStreamType; quality: MWStreamQuality; + providerId?: string; + embedId?: string; captions: MWCaption[]; }; + +export type MWEmbedStream = MWStream & { + embedId: string; +}; diff --git a/src/backend/providers/flixhq.ts b/src/backend/providers/flixhq.ts index 95a09b7b..2232ca9d 100644 --- a/src/backend/providers/flixhq.ts +++ b/src/backend/providers/flixhq.ts @@ -1,7 +1,11 @@ import { compareTitle } from "@/utils/titleMatch"; import { proxiedFetch } from "../helpers/fetch"; import { registerProvider } from "../helpers/register"; -import { MWStreamQuality, MWStreamType } from "../helpers/streams"; +import { + MWCaptionType, + MWStreamQuality, + MWStreamType, +} from "../helpers/streams"; import { MWMediaType } from "../metadata/types"; // const flixHqBase = "https://api.consumet.org/movies/flixhq"; @@ -9,13 +13,52 @@ import { MWMediaType } from "../metadata/types"; // SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326 const flixHqBase = "https://c.delusionz.xyz/movies/flixhq"; +interface FLIXMediaBase { + id: number; + title: string; + url: string; + image: string; +} + +interface FLIXTVSerie extends FLIXMediaBase { + type: "TV Series"; + seasons: number | null; +} + +interface FLIXMovie extends FLIXMediaBase { + type: "Movie"; + releaseDate: string; +} + +function castSubtitles({ url, lang }: { url: string; lang: string }) { + return { + url, + langIso: lang, + type: + url.substring(url.length - 3) === "vtt" + ? MWCaptionType.VTT + : MWCaptionType.SRT, + }; +} + +const qualityMap: Record = { + "360": MWStreamQuality.Q360P, + "540": MWStreamQuality.Q540P, + "480": MWStreamQuality.Q480P, + "720": MWStreamQuality.Q720P, + "1080": MWStreamQuality.Q1080P, +}; + registerProvider({ id: "flixhq", displayName: "FlixHQ", rank: 100, - type: [MWMediaType.MOVIE], + type: [MWMediaType.MOVIE, MWMediaType.SERIES], async scrape({ media, progress }) { + if (!this.type.includes(media.meta.type)) { + throw new Error("Unsupported type"); + } // search for relevant item const searchResults = await proxiedFetch( `/${encodeURIComponent(media.meta.title)}`, @@ -23,11 +66,22 @@ registerProvider({ baseURL: flixHqBase, } ); - const foundItem = searchResults.results.find((v: any) => { - return ( - compareTitle(v.title, media.meta.title) && - v.releaseDate === media.meta.year - ); + const foundItem = searchResults.results.find((v: FLIXMediaBase) => { + if (media.meta.type === MWMediaType.MOVIE) { + 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 + ); + } + return compareTitle(serie.title, media.meta.title); }); if (!foundItem) throw new Error("No watchable item found"); const flixId = foundItem.id; @@ -40,7 +94,7 @@ registerProvider({ id: flixId, }, }); - + if (!mediaInfo.episodes) throw new Error("No watchable item found"); // get stream info from media progress(75); const watchInfo = await proxiedFetch("/watch", { @@ -51,18 +105,22 @@ registerProvider({ }, }); - // get best quality source - const source = watchInfo.sources.reduce((p: any, c: any) => - c.quality > p.quality ? c : p - ); + if (!watchInfo.sources) throw new Error("No watchable item found"); + // get best quality source + // comes sorted by quality in descending order + const source = watchInfo.sources[0]; return { embeds: [], stream: { streamUrl: source.url, - quality: MWStreamQuality.QUNKNOWN, + quality: qualityMap[source.quality], type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4, - captions: [], + captions: watchInfo.subtitles + .filter( + (x: { url: string; lang: string }) => !x.lang.includes("(maybe)") + ) + .map(castSubtitles), }, }; }, diff --git a/src/backend/providers/netfilm.ts b/src/backend/providers/netfilm.ts index 9b4faafa..23a8cf90 100644 --- a/src/backend/providers/netfilm.ts +++ b/src/backend/providers/netfilm.ts @@ -9,13 +9,13 @@ import { MWMediaType } from "../metadata/types"; const netfilmBase = "https://net-film.vercel.app"; -const qualityMap = { - "360": MWStreamQuality.Q360P, - "480": MWStreamQuality.Q480P, - "720": MWStreamQuality.Q720P, - "1080": MWStreamQuality.Q1080P, +const qualityMap: Record = { + 360: MWStreamQuality.Q360P, + 540: MWStreamQuality.Q540P, + 480: MWStreamQuality.Q480P, + 720: MWStreamQuality.Q720P, + 1080: MWStreamQuality.Q1080P, }; -type QualityInMap = keyof typeof qualityMap; registerProvider({ id: "netfilm", @@ -24,6 +24,9 @@ registerProvider({ type: [MWMediaType.MOVIE, MWMediaType.SERIES], async scrape({ media, episode, progress }) { + if (!this.type.includes(media.meta.type)) { + throw new Error("Unsupported type"); + } // search for relevant item const searchResponse = await proxiedFetch( `/api/search?keyword=${encodeURIComponent(media.meta.title)}`, @@ -54,8 +57,8 @@ registerProvider({ const data = watchInfo.data; // get best quality source - const source = data.qualities.reduce((p: any, c: any) => - c.quality > p.quality ? c : p + const source: { url: string; quality: number } = data.qualities.reduce( + (p: any, c: any) => (c.quality > p.quality ? c : p) ); const mappedCaptions = data.subtitles.map((sub: Record) => ({ @@ -71,7 +74,7 @@ registerProvider({ streamUrl: source.url .replace("akm-cdn", "aws-cdn") .replace("gg-cdn", "aws-cdn"), - quality: qualityMap[source.quality as QualityInMap], + quality: qualityMap[source.quality], type: MWStreamType.HLS, captions: mappedCaptions, }, @@ -124,8 +127,8 @@ registerProvider({ const data = episodeStream.data; // get best quality source - const source = data.qualities.reduce((p: any, c: any) => - c.quality > p.quality ? c : p + const source: { url: string; quality: number } = data.qualities.reduce( + (p: any, c: any) => (c.quality > p.quality ? c : p) ); const mappedCaptions = data.subtitles.map((sub: Record) => ({ @@ -141,7 +144,7 @@ registerProvider({ streamUrl: source.url .replace("akm-cdn", "aws-cdn") .replace("gg-cdn", "aws-cdn"), - quality: qualityMap[source.quality as QualityInMap], + quality: qualityMap[source.quality], type: MWStreamType.HLS, captions: mappedCaptions, }, diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 382b7133..ae33aad7 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -36,6 +36,8 @@ export enum Icons { CASTING = "casting", CIRCLE_EXCLAMATION = "circle_exclamation", DOWNLOAD = "download", + GEAR = "gear", + WATCH_PARTY = "watch_party", PICTURE_IN_PICTURE = "pictureInPicture", } @@ -75,11 +77,13 @@ const iconList: Record = { skip_forward: ``, skip_backward: ``, file: ``, - captions: ``, + captions: ``, link: ``, circle_exclamation: ``, casting: "", download: ``, + gear: ``, + watch_party: ``, pictureInPicture: ``, }; diff --git a/src/components/Transition.tsx b/src/components/Transition.tsx index cda3b945..f7d0d533 100644 --- a/src/components/Transition.tsx +++ b/src/components/Transition.tsx @@ -4,7 +4,13 @@ import { TransitionClasses, } from "@headlessui/react"; -type TransitionAnimations = "slide-down" | "slide-up" | "fade" | "none"; +type TransitionAnimations = + | "slide-down" + | "slide-full-left" + | "slide-full-right" + | "slide-up" + | "fade" + | "none"; interface Props { show?: boolean; @@ -41,6 +47,28 @@ function getClasses( }; } + if (animation === "slide-full-left") { + return { + leave: `transition-[transform] ${duration}`, + leaveFrom: "translate-x-0", + leaveTo: "-translate-x-full", + enter: `transition-[transform] ${duration}`, + enterFrom: "-translate-x-full", + enterTo: "translate-x-0", + }; + } + + if (animation === "slide-full-right") { + return { + leave: `transition-[transform] ${duration}`, + leaveFrom: "translate-x-0", + leaveTo: "translate-x-full", + enter: `transition-[transform] ${duration}`, + enterFrom: "translate-x-full", + enterTo: "translate-x-0", + }; + } + if (animation === "fade") { return { leave: `transition-[transform,opacity] ${duration}`, diff --git a/src/components/popout/FloatingAnchor.tsx b/src/components/popout/FloatingAnchor.tsx new file mode 100644 index 00000000..3d492957 --- /dev/null +++ b/src/components/popout/FloatingAnchor.tsx @@ -0,0 +1,47 @@ +import { ReactNode, useEffect, useRef } from "react"; + +export function createFloatingAnchorEvent(id: string): string { + return `__floating::anchor::${id}`; +} + +interface Props { + id: string; + children?: ReactNode; +} + +export function FloatingAnchor(props: Props) { + const ref = useRef(null); + const old = useRef(null); + + useEffect(() => { + if (!ref.current) return; + + let cancelled = false; + function render() { + if (cancelled) return; + + if (ref.current) { + const current = old.current; + const newer = ref.current.getBoundingClientRect(); + const newerStr = JSON.stringify(newer); + if (current !== newerStr) { + old.current = newerStr; + const evtStr = createFloatingAnchorEvent(props.id); + (window as any)[evtStr] = newer; + const evObj = new CustomEvent(createFloatingAnchorEvent(props.id), { + detail: newer, + }); + document.dispatchEvent(evObj); + } + } + window.requestAnimationFrame(render); + } + + window.requestAnimationFrame(render); + return () => { + cancelled = true; + }; + }, [props]); + + return
{props.children}
; +} diff --git a/src/components/popout/FloatingCard.tsx b/src/components/popout/FloatingCard.tsx new file mode 100644 index 00000000..8d894f16 --- /dev/null +++ b/src/components/popout/FloatingCard.tsx @@ -0,0 +1,189 @@ +import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition"; +import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { PopoutSection } from "@/video/components/popouts/PopoutUtils"; +import { useSpringValue, animated, easings } from "@react-spring/web"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { Icon, Icons } from "../Icon"; +import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle"; + +interface FloatingCardProps { + children?: ReactNode; + onClose?: () => void; + for: string; +} + +interface RootFloatingCardProps extends FloatingCardProps { + className?: string; +} + +function CardBase(props: { children: ReactNode }) { + const ref = useRef(null); + const { isMobile } = useIsMobile(); + const height = useSpringValue(0, { + config: { easing: easings.easeInOutSine, duration: 300 }, + }); + const width = useSpringValue(0, { + config: { easing: easings.easeInOutSine, duration: 300 }, + }); + const [pages, setPages] = useState | null>(null); + + const getNewHeight = useCallback( + (updateList = true) => { + if (!ref.current) return; + const children = ref.current.querySelectorAll( + ":scope *[data-floating-page='true']" + ); + if (updateList) setPages(children); + if (children.length === 0) { + height.start(0); + width.start(0); + return; + } + const lastChild = children[children.length - 1]; + const rect = lastChild.getBoundingClientRect(); + const rectHeight = lastChild.scrollHeight; + if (height.get() === 0) { + height.set(rectHeight); + width.set(rect.width); + } else { + height.start(rectHeight); + width.start(rect.width); + } + }, + [height, width] + ); + + useEffect(() => { + if (!ref.current) return; + getNewHeight(); + const observer = new MutationObserver(() => { + getNewHeight(); + }); + observer.observe(ref.current, { + attributes: false, + childList: true, + subtree: false, + }); + return () => { + observer.disconnect(); + }; + }, [getNewHeight]); + + useEffect(() => { + const observer = new ResizeObserver(() => { + getNewHeight(false); + }); + pages?.forEach((el) => observer.observe(el)); + return () => { + observer.disconnect(); + }; + }, [pages, getNewHeight]); + + return ( + + {props.children} + + ); +} + +export function FloatingCard(props: RootFloatingCardProps) { + const { isMobile } = useIsMobile(); + const content = {props.children}; + + if (isMobile) + return ( + + {content} + + ); + + return ( + + {content} + + ); +} + +export function PopoutFloatingCard(props: FloatingCardProps) { + return ( + + ); +} + +export const FloatingCardView = { + Header(props: { + title: string; + description: string; + close?: boolean; + goBack: () => any; + action?: React.ReactNode; + backText?: string; + }) { + let left = ( +
+ + {props.backText || "Go back"} +
+ ); + if (props.close) + left = ( +
+ + Close +
+ ); + + return ( +
+ + +
+
{left}
+
{props.action ?? null}
+
+ +

+ {props.title} +

+

{props.description}

+
+
+ ); + }, + Content(props: { children: React.ReactNode; noSection?: boolean }) { + return ( +
+ {props.noSection ? ( +
+ {props.children} +
+ ) : ( + + {props.children} + + )} + +
+ ); + }, +}; diff --git a/src/components/popout/FloatingContainer.tsx b/src/components/popout/FloatingContainer.tsx new file mode 100644 index 00000000..48e4f5cc --- /dev/null +++ b/src/components/popout/FloatingContainer.tsx @@ -0,0 +1,56 @@ +import { Transition } from "@/components/Transition"; +import React, { ReactNode, useCallback, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +interface Props { + children?: ReactNode; + onClose?: () => void; + show?: boolean; + darken?: boolean; +} + +export function FloatingContainer(props: Props) { + const target = useRef(null); + + useEffect(() => { + function listen(e: MouseEvent) { + target.current = e.target as Element; + } + document.addEventListener("mousedown", listen); + return () => { + document.removeEventListener("mousedown", listen); + }; + }); + + const click = useCallback( + (e: React.MouseEvent) => { + const startedTarget = target.current; + target.current = null; + if (e.currentTarget !== e.target) return; + if (!startedTarget) return; + if (!startedTarget.isEqualNode(e.currentTarget as Element)) return; + if (props.onClose) props.onClose(); + }, + [props] + ); + + return createPortal( + +
+ +
+ + + {props.children} + +
+
, + document.body + ); +} diff --git a/src/components/popout/FloatingDragHandle.tsx b/src/components/popout/FloatingDragHandle.tsx new file mode 100644 index 00000000..81927557 --- /dev/null +++ b/src/components/popout/FloatingDragHandle.tsx @@ -0,0 +1,19 @@ +import { useIsMobile } from "@/hooks/useIsMobile"; + +export function FloatingDragHandle() { + const { isMobile } = useIsMobile(); + + if (!isMobile) return null; + + return ( +
+ ); +} + +export function MobilePopoutSpacer() { + const { isMobile } = useIsMobile(); + + if (!isMobile) return null; + + return
; +} diff --git a/src/components/popout/FloatingView.tsx b/src/components/popout/FloatingView.tsx new file mode 100644 index 00000000..9ae797ee --- /dev/null +++ b/src/components/popout/FloatingView.tsx @@ -0,0 +1,39 @@ +import { Transition } from "@/components/Transition"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { ReactNode } from "react"; + +interface Props { + children?: ReactNode; + show?: boolean; + className?: string; + height?: number; + width?: number; + active?: boolean; // true if a child view is loaded +} + +export function FloatingView(props: Props) { + const { isMobile } = useIsMobile(); + const width = !isMobile ? `${props.width}px` : "100%"; + return ( + +
+ {props.children} +
+
+ ); +} diff --git a/src/components/popout/positions/FloatingCardAnchorPosition.tsx b/src/components/popout/positions/FloatingCardAnchorPosition.tsx new file mode 100644 index 00000000..4e022834 --- /dev/null +++ b/src/components/popout/positions/FloatingCardAnchorPosition.tsx @@ -0,0 +1,80 @@ +import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; + +interface AnchorPositionProps { + children?: ReactNode; + id: string; + className?: string; +} + +export function FloatingCardAnchorPosition(props: AnchorPositionProps) { + const ref = useRef(null); + const [left, setLeft] = useState(0); + const [top, setTop] = useState(0); + const [cardRect, setCardRect] = useState(null); + const [anchorRect, setAnchorRect] = useState(null); + + const calculateAndSetCoords = useCallback( + (anchor: DOMRect, card: DOMRect) => { + const buttonCenter = anchor.left + anchor.width / 2; + const bottomReal = window.innerHeight - anchor.bottom; + + setTop( + window.innerHeight - bottomReal - anchor.height - card.height - 30 + ); + setLeft( + Math.min( + buttonCenter - card.width / 2, + window.innerWidth - card.width - 30 + ) + ); + }, + [] + ); + + useEffect(() => { + if (!anchorRect || !cardRect) return; + calculateAndSetCoords(anchorRect, cardRect); + }, [anchorRect, calculateAndSetCoords, cardRect]); + + useEffect(() => { + if (!ref.current) return; + function checkBox() { + const divRect = ref.current?.getBoundingClientRect(); + setCardRect(divRect ?? null); + } + checkBox(); + const observer = new ResizeObserver(checkBox); + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + const evtStr = createFloatingAnchorEvent(props.id); + if ((window as any)[evtStr]) setAnchorRect((window as any)[evtStr]); + function listen(ev: CustomEvent) { + setAnchorRect(ev.detail); + } + document.addEventListener(evtStr, listen as any); + return () => { + document.removeEventListener(evtStr, listen as any); + }; + }, [props.id]); + + return ( +
+ {props.children} +
+ ); +} diff --git a/src/components/popout/positions/FloatingCardMobilePosition.tsx b/src/components/popout/positions/FloatingCardMobilePosition.tsx new file mode 100644 index 00000000..059f6667 --- /dev/null +++ b/src/components/popout/positions/FloatingCardMobilePosition.tsx @@ -0,0 +1,91 @@ +import { useSpring, animated, config } from "@react-spring/web"; +import { useDrag } from "@use-gesture/react"; +import { ReactNode, useEffect, useRef, useState } from "react"; + +interface MobilePositionProps { + children?: ReactNode; + className?: string; + onClose?: () => void; +} + +export function FloatingCardMobilePosition(props: MobilePositionProps) { + const ref = useRef(null); + const closing = useRef(false); + const [cardRect, setCardRect] = useState(null); + const [{ y }, api] = useSpring(() => ({ + y: 0, + onRest() { + if (!closing.current) return; + if (props.onClose) props.onClose(); + }, + })); + + const bind = useDrag( + ({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => { + if (closing.current) return; + const height = cardRect?.height ?? 0; + if (last) { + // if past half height downwards + // OR Y velocity is past 0.5 AND going down AND 20 pixels below start position + if (my > height * 0.5 || (vy > 0.5 && dy > 0 && my > 20)) { + api.start({ + y: height * 1.2, + immediate: false, + config: { ...config.wobbly, velocity: vy, clamp: true }, + }); + closing.current = true; + } else { + api.start({ + y: 0, + immediate: false, + config: config.wobbly, + }); + } + } else { + api.start({ y: my, immediate: true }); + } + }, + { + from: () => [0, y.get()], + filterTaps: true, + bounds: { top: 0 }, + rubberband: true, + } + ); + + useEffect(() => { + if (!ref.current) return; + function checkBox() { + const divRect = ref.current?.getBoundingClientRect(); + setCardRect(divRect ?? null); + } + checkBox(); + const observer = new ResizeObserver(checkBox); + observer.observe(ref.current); + return () => { + observer.disconnect(); + }; + }, []); + + return ( +
+ + {props.children} + +
+ ); +} diff --git a/src/hooks/useFloatingRouter.ts b/src/hooks/useFloatingRouter.ts new file mode 100644 index 00000000..0e9db907 --- /dev/null +++ b/src/hooks/useFloatingRouter.ts @@ -0,0 +1,60 @@ +import { useLayoutEffect, useState } from "react"; + +export function useFloatingRouter(initial = "/") { + const [route, setRoute] = useState( + initial.split("/").filter((v) => v.length > 0) + ); + const [previousRoute, setPreviousRoute] = useState(route); + const currentPage = route[route.length - 1] ?? "/"; + + useLayoutEffect(() => { + if (previousRoute.length === route.length) return; + // when navigating backwards, we delay the updating by a bit so transitions can be applied correctly + setTimeout(() => { + setPreviousRoute(route); + }, 20); + }, [route, previousRoute]); + + function navigate(path: string) { + const newRoute = path.split("/").filter((v) => v.length > 0); + if (newRoute.length > previousRoute.length) setPreviousRoute(newRoute); + setRoute(newRoute); + } + + function isActive(page: string) { + if (page === "/") return true; + const index = previousRoute.indexOf(page); + if (index === -1) return false; // not active + if (index === previousRoute.length - 1) return false; // active but latest route so shouldnt be counted as active + return true; + } + + function isCurrentPage(page: string) { + return page === currentPage; + } + + function isLoaded(page: string) { + if (page === "/") return true; + return route.includes(page); + } + + function pageProps(page: string) { + return { + show: isCurrentPage(page), + active: isActive(page), + }; + } + + function reset() { + navigate("/"); + } + + return { + navigate, + reset, + isLoaded, + isCurrentPage, + pageProps, + isActive, + }; +} diff --git a/src/setup/App.tsx b/src/setup/App.tsx index e82f57d7..331337db 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -13,6 +13,7 @@ import { ProviderTesterView } from "@/views/developer/ProviderTesterView"; import { EmbedTesterView } from "@/views/developer/EmbedTesterView"; import { BannerContextProvider } from "@/hooks/useBanner"; import { Layout } from "@/setup/Layout"; +import { TestView } from "@/views/developer/TestView"; function App() { return ( @@ -42,6 +43,7 @@ function App() { {/* other */} +
- - - - + +
@@ -156,15 +152,12 @@ export function VideoPlayer(props: Props) { <>
- - -
+ + - - )} diff --git a/src/video/components/VideoPlayerBase.tsx b/src/video/components/VideoPlayerBase.tsx index 954bf0d1..2ce6784d 100644 --- a/src/video/components/VideoPlayerBase.tsx +++ b/src/video/components/VideoPlayerBase.tsx @@ -8,6 +8,7 @@ import { useVideoPlayerDescriptor, VideoPlayerContextProvider, } from "../state/hooks"; +import { MetaAction } from "./actions/MetaAction"; import { VideoElementInternal } from "./internal/VideoElementInternal"; export interface VideoPlayerBaseProps { @@ -27,7 +28,9 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) { const children = typeof props.children === "function" - ? props.children({ isFullscreen: videoInterface.isFullscreen }) + ? props.children({ + isFullscreen: videoInterface.isFullscreen, + }) : props.children; // TODO move error boundary to only decorated, shouldn't have styling @@ -42,6 +45,7 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) { : "", ].join(" ")} > + diff --git a/src/video/components/actions/BackdropAction.tsx b/src/video/components/actions/BackdropAction.tsx index dcceff60..d7e75605 100644 --- a/src/video/components/actions/BackdropAction.tsx +++ b/src/video/components/actions/BackdropAction.tsx @@ -19,8 +19,20 @@ export function BackdropAction(props: BackdropActionProps) { const timeout = useRef | null>(null); const clickareaRef = useRef(null); + const lastTouchEnd = useRef(0); + const handleMouseMove = useCallback(() => { - if (!moved) setMoved(true); + if (!moved) { + setTimeout(() => { + const isTouch = Date.now() - lastTouchEnd.current < 200; + if (!isTouch) { + setMoved(true); + } + }, 20); + return; + } + + // remove after all if (timeout.current) clearTimeout(timeout.current); timeout.current = setTimeout(() => { if (moved) setMoved(false); @@ -32,8 +44,6 @@ export function BackdropAction(props: BackdropActionProps) { setMoved(false); }, [setMoved]); - const [lastTouchEnd, setLastTouchEnd] = useState(0); - const handleClick = useCallback( ( e: React.MouseEvent | React.TouchEvent @@ -43,13 +53,17 @@ export function BackdropAction(props: BackdropActionProps) { if (videoInterface.popout !== null) return; if ((e as React.TouchEvent).type === "touchend") { - setLastTouchEnd(Date.now()); + lastTouchEnd.current = Date.now(); return; } + if ((e as React.MouseEvent).button !== 0) { + return; // not main button (left click), exit event + } + setTimeout(() => { - if (Date.now() - lastTouchEnd < 200) { - setMoved(!moved); + if (Date.now() - lastTouchEnd.current < 200) { + setMoved((v) => !v); return; } @@ -57,7 +71,7 @@ export function BackdropAction(props: BackdropActionProps) { else controls.play(); }, 20); }, - [controls, mediaPlaying, videoInterface, lastTouchEnd, moved] + [controls, mediaPlaying, videoInterface] ); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { diff --git a/src/video/components/actions/CaptionsSelectionAction.tsx b/src/video/components/actions/CaptionsSelectionAction.tsx deleted file mode 100644 index d6cc4328..00000000 --- a/src/video/components/actions/CaptionsSelectionAction.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Icons } from "@/components/Icon"; -import { useVideoPlayerDescriptor } from "@/video/state/hooks"; -import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; -import { useControls } from "@/video/state/logic/controls"; -import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor"; -import { useIsMobile } from "@/hooks/useIsMobile"; -import { useTranslation } from "react-i18next"; - -interface Props { - className?: string; -} - -export function CaptionsSelectionAction(props: Props) { - const { t } = useTranslation(); - const descriptor = useVideoPlayerDescriptor(); - const controls = useControls(descriptor); - const { isMobile } = useIsMobile(); - - return ( -
-
- - controls.openPopout("captions")} - icon={Icons.CAPTIONS} - /> - -
-
- ); -} diff --git a/src/video/components/actions/DividerAction.tsx b/src/video/components/actions/DividerAction.tsx new file mode 100644 index 00000000..ac1090f7 --- /dev/null +++ b/src/video/components/actions/DividerAction.tsx @@ -0,0 +1,12 @@ +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useMeta } from "@/video/state/logic/meta"; +import { MWMediaType } from "@/backend/metadata/types"; + +export function DividerAction() { + const descriptor = useVideoPlayerDescriptor(); + const meta = useMeta(descriptor); + + if (meta?.meta.meta.type !== MWMediaType.SERIES) return null; + + return
; +} diff --git a/src/video/components/actions/MetaAction.tsx b/src/video/components/actions/MetaAction.tsx new file mode 100644 index 00000000..35b2543d --- /dev/null +++ b/src/video/components/actions/MetaAction.tsx @@ -0,0 +1,59 @@ +import { MWCaption } from "@/backend/helpers/streams"; +import { DetailedMeta } from "@/backend/metadata/getmeta"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useMeta } from "@/video/state/logic/meta"; +import { useProgress } from "@/video/state/logic/progress"; +import { useEffect } from "react"; + +export type WindowMeta = { + meta: DetailedMeta; + captions: MWCaption[]; + episode?: { + episodeId: string; + seasonId: string; + }; + seasons?: { + id: string; + number: number; + title: string; + episodes?: { id: string; number: number; title: string }[]; + }[]; + progress: { + time: number; + duration: number; + }; +} | null; + +declare global { + interface Window { + meta?: Record; + } +} + +export function MetaAction() { + const descriptor = useVideoPlayerDescriptor(); + const meta = useMeta(descriptor); + const progress = useProgress(descriptor); + + useEffect(() => { + if (!window.meta) window.meta = {}; + if (meta) { + window.meta[descriptor] = { + meta: meta.meta, + captions: meta.captions, + seasons: meta.seasons, + episode: meta.episode, + progress: { + time: progress.time, + duration: progress.duration, + }, + }; + } + + return () => { + if (window.meta) delete window.meta[descriptor]; + }; + }, [meta, descriptor, progress]); + + return null; +} diff --git a/src/video/components/actions/SeriesSelectionAction.tsx b/src/video/components/actions/SeriesSelectionAction.tsx index 2a6b2b35..4595eabe 100644 --- a/src/video/components/actions/SeriesSelectionAction.tsx +++ b/src/video/components/actions/SeriesSelectionAction.tsx @@ -4,9 +4,9 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useMeta } from "@/video/state/logic/meta"; import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; import { useControls } from "@/video/state/logic/controls"; -import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor"; import { useInterface } from "@/video/state/logic/interface"; import { useTranslation } from "react-i18next"; +import { FloatingAnchor } from "@/components/popout/FloatingAnchor"; interface Props { className?: string; @@ -24,7 +24,7 @@ export function SeriesSelectionAction(props: Props) { return (
- + controls.openPopout("episodes")} /> - +
); diff --git a/src/video/components/actions/SourceSelectionAction.tsx b/src/video/components/actions/SettingsAction.tsx similarity index 55% rename from src/video/components/actions/SourceSelectionAction.tsx rename to src/video/components/actions/SettingsAction.tsx index 66784da8..b012639a 100644 --- a/src/video/components/actions/SourceSelectionAction.tsx +++ b/src/video/components/actions/SettingsAction.tsx @@ -2,33 +2,38 @@ import { Icons } from "@/components/Icon"; import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; import { useControls } from "@/video/state/logic/controls"; -import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor"; import { useInterface } from "@/video/state/logic/interface"; +import { useIsMobile } from "@/hooks/useIsMobile"; import { useTranslation } from "react-i18next"; +import { FloatingAnchor } from "@/components/popout/FloatingAnchor"; interface Props { className?: string; } -export function SourceSelectionAction(props: Props) { +export function SettingsAction(props: Props) { const { t } = useTranslation(); const descriptor = useVideoPlayerDescriptor(); - const videoInterface = useInterface(descriptor); const controls = useControls(descriptor); + const videoInterface = useInterface(descriptor); + const { isMobile } = useIsMobile(false); return (
- + controls.openPopout("source")} + active={videoInterface.popout === "settings"} + className={props.className} + onClick={() => controls.openPopout("settings")} + text={ + isMobile + ? (t("videoPlayer.buttons.settings") as string) + : undefined + } + icon={Icons.GEAR} /> - +
); diff --git a/src/video/components/actions/list-entries/CaptionsSelectionAction.tsx b/src/video/components/actions/list-entries/CaptionsSelectionAction.tsx new file mode 100644 index 00000000..8dfe2ec3 --- /dev/null +++ b/src/video/components/actions/list-entries/CaptionsSelectionAction.tsx @@ -0,0 +1,17 @@ +import { Icons } from "@/components/Icon"; +import { useTranslation } from "react-i18next"; +import { PopoutListAction } from "../../popouts/PopoutUtils"; + +interface Props { + onClick: () => any; +} + +export function CaptionsSelectionAction(props: Props) { + const { t } = useTranslation(); + + return ( + + {t("videoPlayer.buttons.captions")} + + ); +} diff --git a/src/video/components/actions/DownloadAction.tsx b/src/video/components/actions/list-entries/DownloadAction.tsx similarity index 61% rename from src/video/components/actions/DownloadAction.tsx rename to src/video/components/actions/list-entries/DownloadAction.tsx index 307f14c7..76910efd 100644 --- a/src/video/components/actions/DownloadAction.tsx +++ b/src/video/components/actions/list-entries/DownloadAction.tsx @@ -3,39 +3,29 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useSource } from "@/video/state/logic/source"; import { MWStreamType } from "@/backend/helpers/streams"; import { normalizeTitle } from "@/utils/normalizeTitle"; -import { useIsMobile } from "@/hooks/useIsMobile"; import { useTranslation } from "react-i18next"; import { useMeta } from "@/video/state/logic/meta"; -import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; +import { PopoutListAction } from "../../popouts/PopoutUtils"; -interface Props { - className?: string; -} - -export function DownloadAction(props: Props) { +export function DownloadAction() { const descriptor = useVideoPlayerDescriptor(); const sourceInterface = useSource(descriptor); - const { isMobile } = useIsMobile(); const { t } = useTranslation(); const meta = useMeta(descriptor); const isHLS = sourceInterface.source?.type === MWStreamType.HLS; + if (isHLS) return null; + const title = meta?.meta.meta.title; return ( - - - + {t("videoPlayer.buttons.download")} + ); } diff --git a/src/video/components/actions/QualityDisplayAction.tsx b/src/video/components/actions/list-entries/QualityDisplayAction.tsx similarity index 100% rename from src/video/components/actions/QualityDisplayAction.tsx rename to src/video/components/actions/list-entries/QualityDisplayAction.tsx diff --git a/src/video/components/actions/list-entries/SourceSelectionAction.tsx b/src/video/components/actions/list-entries/SourceSelectionAction.tsx new file mode 100644 index 00000000..f230646f --- /dev/null +++ b/src/video/components/actions/list-entries/SourceSelectionAction.tsx @@ -0,0 +1,23 @@ +import { Icon, Icons } from "@/components/Icon"; +import { useTranslation } from "react-i18next"; +import { PopoutListAction } from "../../popouts/PopoutUtils"; +import { QualityDisplayAction } from "./QualityDisplayAction"; + +interface Props { + onClick?: () => any; +} + +export function SourceSelectionAction(props: Props) { + const { t } = useTranslation(); + + return ( + } + noChevron + > + {t("videoPlayer.buttons.source")} + + ); +} diff --git a/src/video/components/controllers/SourceController.tsx b/src/video/components/controllers/SourceController.tsx index 64c6403c..14d5ad93 100644 --- a/src/video/components/controllers/SourceController.tsx +++ b/src/video/components/controllers/SourceController.tsx @@ -8,6 +8,8 @@ interface SourceControllerProps { source: string; type: MWStreamType; quality: MWStreamQuality; + providerId?: string; + embedId?: string; } export function SourceController(props: SourceControllerProps) { diff --git a/src/video/components/parts/VideoPlayerIconButton.tsx b/src/video/components/parts/VideoPlayerIconButton.tsx index e75a4673..156dffd5 100644 --- a/src/video/components/parts/VideoPlayerIconButton.tsx +++ b/src/video/components/parts/VideoPlayerIconButton.tsx @@ -33,7 +33,7 @@ export const VideoPlayerIconButton = forwardRef< className={[ "flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 transition-colors duration-100", props.active ? "!bg-denim-500 !bg-opacity-100" : "", - !props.noPadding ? (props.wide ? "py-2 px-4" : "p-2") : "", + !props.noPadding ? (props.wide ? "p-2 sm:px-4" : "p-2") : "", !props.disabled ? "group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100" : "", diff --git a/src/video/components/popouts/CaptionSelectionPopout.tsx b/src/video/components/popouts/CaptionSelectionPopout.tsx index 2b4bf0aa..7d3b9a98 100644 --- a/src/video/components/popouts/CaptionSelectionPopout.tsx +++ b/src/video/components/popouts/CaptionSelectionPopout.tsx @@ -5,6 +5,9 @@ import { } from "@/backend/helpers/captions"; import { MWCaption } from "@/backend/helpers/streams"; import { Icon, Icons } from "@/components/Icon"; +import { FloatingCardView } from "@/components/popout/FloatingCard"; +import { FloatingView } from "@/components/popout/FloatingView"; +import { useFloatingRouter } from "@/hooks/useFloatingRouter"; import { useLoading } from "@/hooks/useLoading"; import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useControls } from "@/video/state/logic/controls"; @@ -18,7 +21,10 @@ function makeCaptionId(caption: MWCaption, isLinked: boolean): string { return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`; } -export function CaptionSelectionPopout() { +export function CaptionSelectionPopout(props: { + router: ReturnType; + prefix: string; +}) { const { t } = useTranslation(); const descriptor = useVideoPlayerDescriptor(); @@ -66,11 +72,17 @@ export function CaptionSelectionPopout() { } return ( - <> - -
{t("videoPlayer.popouts.captions")}
-
-
+ + props.router.navigate("/")} + /> + -

+

{t("videoPlayer.popouts.linkedCaptions")}

@@ -126,7 +138,7 @@ export function CaptionSelectionPopout() { ))}
-
- + + ); } diff --git a/src/video/components/popouts/EpisodeSelectionPopout.tsx b/src/video/components/popouts/EpisodeSelectionPopout.tsx index 1f167731..61218170 100644 --- a/src/video/components/popouts/EpisodeSelectionPopout.tsx +++ b/src/video/components/popouts/EpisodeSelectionPopout.tsx @@ -12,19 +12,22 @@ import { useMeta } from "@/video/state/logic/meta"; import { useControls } from "@/video/state/logic/controls"; import { useWatchedContext } from "@/state/watched"; import { useTranslation } from "react-i18next"; -import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; +import { FloatingView } from "@/components/popout/FloatingView"; +import { useFloatingRouter } from "@/hooks/useFloatingRouter"; +import { FloatingCardView } from "@/components/popout/FloatingCard"; +import { PopoutListEntry } from "./PopoutUtils"; export function EpisodeSelectionPopout() { const params = useParams<{ media: string; }>(); const { t } = useTranslation(); + const { pageProps, navigate } = useFloatingRouter("/episodes"); const descriptor = useVideoPlayerDescriptor(); const meta = useMeta(descriptor); const controls = useControls(descriptor); - const [isPickingSeason, setIsPickingSeason] = useState(false); const [currentVisibleSeason, setCurrentVisibleSeason] = useState<{ seasonId: string; season?: MWSeasonWithEpisodeMeta; @@ -40,7 +43,6 @@ export function EpisodeSelectionPopout() { seasonId: sId, season: undefined, }); - setIsPickingSeason(false); reqSeasonMeta(decodeJWId(params.media)?.id as string, sId).then((v) => { if (v?.meta.type !== MWMediaType.SERIES) return; setCurrentVisibleSeason({ @@ -79,80 +81,59 @@ export function EpisodeSelectionPopout() { )?.episodes; }, [meta, currentSeasonId, currentVisibleSeason]); - const toggleIsPickingSeason = () => { - setIsPickingSeason(!isPickingSeason); - }; - const setSeason = (id: string) => { requestSeason(id); setCurrentVisibleSeason({ seasonId: id }); + navigate("/episodes"); }; const { watched } = useWatchedContext(); - const titlePositionClass = useMemo(() => { - const offset = isPickingSeason ? "left-0" : "left-10"; - return [ - "absolute w-full transition-[left,opacity] duration-200", - offset, - ].join(" "); - }, [isPickingSeason]); + const closePopout = () => { + controls.closePopout(); + }; return ( <> - -
- - - {currentSeasonInfo?.title || ""} - - - {t("videoPlayer.popouts.seasons")} - -
-
-
- + + navigate("/episodes")} + backText={`To ${currentSeasonInfo?.title.toLowerCase()}`} + /> + {currentSeasonInfo ? meta?.seasons?.map?.((season) => ( setSeason(season.id)} - isOnDarkBackground > {season.title} )) : "No season"} - - + + + + navigate("/episodes/seasons")} + className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white" + > + Other seasons + + + } + /> + {loading ? (
@@ -165,7 +146,7 @@ export function EpisodeSelectionPopout() { className="text-xl text-bink-600" />

- {t("videoPLayer.popouts.errors.loadingWentWrong", { + {t("videoPlayer.popouts.errors.loadingWentWrong", { seasonTitle: currentSeasonInfo?.title?.toLowerCase(), })}

@@ -201,8 +182,8 @@ export function EpisodeSelectionPopout() { : "No episodes"}
)} -
-
+ + ); } diff --git a/src/video/components/popouts/PopoutProviderAction.tsx b/src/video/components/popouts/PopoutProviderAction.tsx index c2b495aa..3c90d46d 100644 --- a/src/video/components/popouts/PopoutProviderAction.tsx +++ b/src/video/components/popouts/PopoutProviderAction.tsx @@ -1,76 +1,35 @@ -import { Transition } from "@/components/Transition"; import { useSyncPopouts } from "@/video/components/hooks/useSyncPopouts"; import { EpisodeSelectionPopout } from "@/video/components/popouts/EpisodeSelectionPopout"; -import { SourceSelectionPopout } from "@/video/components/popouts/SourceSelectionPopout"; -import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelectionPopout"; +import { SettingsPopout } from "@/video/components/popouts/SettingsPopout"; import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useControls } from "@/video/state/logic/controls"; -import { useIsMobile } from "@/hooks/useIsMobile"; -import { - useInterface, - VideoInterfaceEvent, -} from "@/video/state/logic/interface"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useInterface } from "@/video/state/logic/interface"; +import { useCallback } from "react"; +import { PopoutFloatingCard } from "@/components/popout/FloatingCard"; +import { FloatingContainer } from "@/components/popout/FloatingContainer"; import "./Popouts.css"; -function ShowPopout(props: { popoutId: string | null }) { - // only updates popout id when a new one is set, so transitions look good - const [popoutId, setPopoutId] = useState(props.popoutId); - useEffect(() => { - if (!props.popoutId) return; - setPopoutId(props.popoutId); - }, [props]); - - if (popoutId === "episodes") return ; - if (popoutId === "source") return ; - if (popoutId === "captions") return ; - return ( -
- Unknown popout -
- ); -} - -function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) { - const ref = useRef(null); - const [right, setRight] = useState(0); - const [bottom, setBottom] = useState(0); - const [width, setWidth] = useState(0); - - const { isMobile } = useIsMobile(true); - - const calculateAndSetCoords = useCallback((rect: DOMRect, w: number) => { - const buttonCenter = rect.left + rect.width / 2; - - setBottom(rect ? rect.height + 30 : 30); - setRight(Math.max(window.innerWidth - buttonCenter - w / 2, 30)); - }, []); - - useEffect(() => { - if (!props.videoInterface.popoutBounds) return; - calculateAndSetCoords(props.videoInterface.popoutBounds, width); - }, [props.videoInterface.popoutBounds, calculateAndSetCoords, width]); - - useEffect(() => { - const rect = ref.current?.getBoundingClientRect(); - setWidth(rect?.width ?? 0); - }, []); +function ShowPopout(props: { popoutId: string | null; onClose: () => void }) { + const popoutMap = { + settings: , + episodes: , + }; return ( -
- -
+ <> + {Object.entries(popoutMap).map(([id, el]) => ( + + + {el} + + + ))} + ); } @@ -80,20 +39,9 @@ export function PopoutProviderAction() { const controls = useControls(descriptor); useSyncPopouts(descriptor); - const handleClick = useCallback(() => { + const onClose = useCallback(() => { controls.closePopout(); }, [controls]); - return ( - -
-
- -
- - ); + return ; } diff --git a/src/video/components/popouts/PopoutUtils.tsx b/src/video/components/popouts/PopoutUtils.tsx index be5b2b38..3573a86f 100644 --- a/src/video/components/popouts/PopoutUtils.tsx +++ b/src/video/components/popouts/PopoutUtils.tsx @@ -3,16 +3,32 @@ import { Spinner } from "@/components/layout/Spinner"; import { ProgressRing } from "@/components/layout/ProgressRing"; import { createRef, useEffect, useRef } from "react"; -interface PopoutListEntryTypes { +interface PopoutListEntryBaseTypes { active?: boolean; children: React.ReactNode; onClick?: () => void; isOnDarkBackground?: boolean; +} + +interface PopoutListEntryTypes extends PopoutListEntryBaseTypes { percentageCompleted?: number; loading?: boolean; errored?: boolean; } +interface PopoutListEntryRootTypes extends PopoutListEntryBaseTypes { + right?: React.ReactNode; + noChevron?: boolean; +} + +interface PopoutListActionTypes extends PopoutListEntryBaseTypes { + icon?: Icons; + right?: React.ReactNode; + download?: string; + href?: string; + noChevron?: boolean; +} + interface ScrollToActiveProps { children: React.ReactNode; className?: string; @@ -87,7 +103,7 @@ export function PopoutSection(props: PopoutSectionProps) { ); } -export function PopoutListEntry(props: PopoutListEntryTypes) { +export function PopoutListEntryBase(props: PopoutListEntryRootTypes) { const bg = props.isOnDarkBackground ? "bg-ash-200" : "bg-ash-400"; const hover = props.isOnDarkBackground ? "hover:bg-ash-200" @@ -96,7 +112,7 @@ export function PopoutListEntry(props: PopoutListEntryTypes) { return (
)} {props.children} -
- {props.errored && ( - - )} - {props.loading && !props.errored && ( - - )} - {!props.loading && !props.errored && ( +
+ {!props.noChevron && ( )} - {props.percentageCompleted && !props.loading && !props.errored ? ( - 90 ? 100 : props.percentageCompleted - } - /> - ) : ( - "" - )} + {props.right}
); } + +export function PopoutListEntry(props: PopoutListEntryTypes) { + return ( + + {props.errored && ( + + )} + {props.loading && !props.errored && ( + + )} + {props.percentageCompleted && !props.loading && !props.errored ? ( + 90 ? 100 : props.percentageCompleted + } + /> + ) : ( + "" + )} + + } + > + {props.children} + + ); +} + +export function PopoutListAction(props: PopoutListActionTypes) { + const entry = ( + +
+ {props.icon ? : null} +
{props.children}
+
+
+ ); + + return props.href ? ( + + {entry} + + ) : ( + entry + ); +} diff --git a/src/video/components/popouts/Popouts.css b/src/video/components/popouts/Popouts.css index 143930dc..5f8cfd89 100644 --- a/src/video/components/popouts/Popouts.css +++ b/src/video/components/popouts/Popouts.css @@ -12,4 +12,4 @@ .popout-wrapper ::-webkit-scrollbar { /* For some reason the styles don't get applied without the width */ width: 13px; -} +} \ No newline at end of file diff --git a/src/video/components/popouts/SettingsPopout.tsx b/src/video/components/popouts/SettingsPopout.tsx new file mode 100644 index 00000000..9640397d --- /dev/null +++ b/src/video/components/popouts/SettingsPopout.tsx @@ -0,0 +1,29 @@ +import { FloatingCardView } from "@/components/popout/FloatingCard"; +import { FloatingDragHandle } from "@/components/popout/FloatingDragHandle"; +import { FloatingView } from "@/components/popout/FloatingView"; +import { useFloatingRouter } from "@/hooks/useFloatingRouter"; +import { DownloadAction } from "@/video/components/actions/list-entries/DownloadAction"; +import { CaptionsSelectionAction } from "@/video/components/actions/list-entries/CaptionsSelectionAction"; +import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction"; +import { CaptionSelectionPopout } from "./CaptionSelectionPopout"; +import { SourceSelectionPopout } from "./SourceSelectionPopout"; + +export function SettingsPopout() { + const floatingRouter = useFloatingRouter(); + const { pageProps, navigate } = floatingRouter; + + return ( + <> + + + + + navigate("/source")} /> + navigate("/captions")} /> + + + + + + ); +} diff --git a/src/video/components/popouts/SourceSelectionPopout.tsx b/src/video/components/popouts/SourceSelectionPopout.tsx index 2eee6339..62bec772 100644 --- a/src/video/components/popouts/SourceSelectionPopout.tsx +++ b/src/video/components/popouts/SourceSelectionPopout.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from "react"; -import { Icon, Icons } from "@/components/Icon"; +import { Icons } from "@/components/Icon"; import { useLoading } from "@/hooks/useLoading"; import { Loading } from "@/components/layout/Loading"; import { IconPatch } from "@/components/buttons/IconPatch"; @@ -15,12 +15,17 @@ import { runEmbedScraper, runProvider } from "@/backend/helpers/run"; import { MWProviderScrapeResult } from "@/backend/helpers/provider"; import { useTranslation } from "react-i18next"; import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed"; -import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; +import { FloatingCardView } from "@/components/popout/FloatingCard"; +import { FloatingView } from "@/components/popout/FloatingView"; +import { useFloatingRouter } from "@/hooks/useFloatingRouter"; +import { useSource } from "@/video/state/logic/source"; +import { PopoutListEntry } from "./PopoutUtils"; interface EmbedEntryProps { name: string; type: MWEmbedType; url: string; + active: boolean; onSelect: (stream: MWStream) => void; } @@ -40,6 +45,7 @@ export function EmbedEntry(props: EmbedEntryProps) { isOnDarkBackground loading={loading} errored={!!error} + active={props.active} onClick={() => { scrapeEmbed(); }} @@ -49,12 +55,18 @@ export function EmbedEntry(props: EmbedEntryProps) { ); } -export function SourceSelectionPopout() { +export function SourceSelectionPopout(props: { + router: ReturnType; + prefix: string; +}) { const { t } = useTranslation(); const descriptor = useVideoPlayerDescriptor(); const controls = useControls(descriptor); const meta = useMeta(descriptor); + const { source } = useSource(descriptor); + const providerRef = useRef(null); + const providers = useMemo( () => meta @@ -66,7 +78,6 @@ export function SourceSelectionPopout() { const [selectedProvider, setSelectedProvider] = useState(null); const [scrapeResult, setScrapeResult] = useState(null); - const showingProvider = !!selectedProvider; const selectedProviderPopulated = useMemo( () => providers.find((v) => v.id === selectedProvider) ?? null, [providers, selectedProvider] @@ -91,6 +102,8 @@ export function SourceSelectionPopout() { quality: stream.quality, source: stream.streamUrl, type: stream.type, + embedId: stream.embedId, + providerId: providerRef.current ?? undefined, }); if (meta) { controls.setMeta({ @@ -101,11 +114,11 @@ export function SourceSelectionPopout() { controls.closePopout(); } - const providerRef = useRef(null); const selectProvider = (providerId?: string) => { if (!providerId) { providerRef.current = null; setSelectedProvider(null); + props.router.navigate(`/${props.prefix}/source`); return; } @@ -135,16 +148,9 @@ export function SourceSelectionPopout() { }); providerRef.current = providerId; setSelectedProvider(providerId); + props.router.navigate(`/${props.prefix}/source/embeds`); }; - const titlePositionClass = useMemo(() => { - const offset = !showingProvider ? "left-0" : "left-10"; - return [ - "absolute w-full transition-[left,opacity] duration-200", - offset, - ].join(" "); - }, [showingProvider]); - const visibleEmbeds = useMemo(() => { const embeds = scrapeResult?.embeds || []; @@ -174,45 +180,44 @@ export function SourceSelectionPopout() { return ( <> - -
- - - {selectedProviderPopulated?.displayName ?? ""} - - - {t("videoPlayer.popouts.sources")} - -
-
-
- + {/* List providers */} + + props.router.navigate("/")} + /> + + {providers.map((v) => ( + { + selectProvider(v.id); + }} + > + {v.displayName} + + ))} + + + + {/* List embeds */} + + props.router.navigate(`/${props.prefix}`)} + /> + {loading ? (
@@ -237,6 +242,10 @@ export function SourceSelectionPopout() { onClick={() => { if (scrapeResult.stream) selectSource(scrapeResult.stream); }} + active={ + selectedProviderPopulated?.id === source?.providerId && + selectedProviderPopulated?.id === source?.embedId + } > Native source @@ -248,6 +257,7 @@ export function SourceSelectionPopout() { name={v.displayName ?? ""} key={v.url} url={v.url} + active={false} // TODO add embed id extractor onSelect={(stream) => { selectSource(stream); }} @@ -268,22 +278,8 @@ export function SourceSelectionPopout() { )} )} - - -
- {providers.map((v) => ( - { - selectProvider(v.id); - }} - > - {v.displayName} - - ))} -
-
-
+
+
); } diff --git a/src/video/state/logic/source.ts b/src/video/state/logic/source.ts index a940a96e..6f2d0afb 100644 --- a/src/video/state/logic/source.ts +++ b/src/video/state/logic/source.ts @@ -9,6 +9,8 @@ export type VideoSourceEvent = { quality: MWStreamQuality; url: string; type: MWStreamType; + providerId?: string; + embedId?: string; caption: null | { id: string; url: string; diff --git a/src/video/state/providers/castingStateProvider.ts b/src/video/state/providers/castingStateProvider.ts index ab111f37..5f9490ce 100644 --- a/src/video/state/providers/castingStateProvider.ts +++ b/src/video/state/providers/castingStateProvider.ts @@ -133,6 +133,8 @@ export function createCastingStateProvider( type: source.type, url: source.source, caption: null, + embedId: source.embedId, + providerId: source.providerId, }; resetStateForSource(descriptor, state); updateSource(descriptor, state); @@ -224,6 +226,8 @@ export function createCastingStateProvider( quality: state.source.quality, source: state.source.url, type: state.source.type, + embedId: state.source.embedId, + providerId: state.source.providerId, }); return { diff --git a/src/video/state/providers/providerTypes.ts b/src/video/state/providers/providerTypes.ts index 1643a980..3a01b145 100644 --- a/src/video/state/providers/providerTypes.ts +++ b/src/video/state/providers/providerTypes.ts @@ -4,6 +4,8 @@ type VideoPlayerSource = { source: string; type: MWStreamType; quality: MWStreamQuality; + providerId?: string; + embedId?: string; } | null; export type VideoPlayerStateController = { diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts index 0f0971b5..4b085133 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -189,6 +189,8 @@ export function createVideoStateProvider( type: source.type, url: source.source, caption: null, + embedId: source.embedId, + providerId: source.providerId, }; updateSource(descriptor, state); }, @@ -334,6 +336,8 @@ export function createVideoStateProvider( quality: state.source.quality, source: state.source.url, type: state.source.type, + embedId: state.source.embedId, + providerId: state.source.providerId, }); return { diff --git a/src/video/state/types.ts b/src/video/state/types.ts index 6b04a55a..8b27c25e 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -58,6 +58,8 @@ export type VideoPlayerState = { quality: MWStreamQuality; url: string; type: MWStreamType; + providerId?: string; + embedId?: string; caption: null | { url: string; id: string; diff --git a/src/views/developer/DeveloperView.tsx b/src/views/developer/DeveloperView.tsx index 419293a5..c671786f 100644 --- a/src/views/developer/DeveloperView.tsx +++ b/src/views/developer/DeveloperView.tsx @@ -20,6 +20,7 @@ export function DeveloperView() { linkText="Embed scraper tester" /> +
); diff --git a/src/views/developer/TestView.tsx b/src/views/developer/TestView.tsx new file mode 100644 index 00000000..8dc1ccdd --- /dev/null +++ b/src/views/developer/TestView.tsx @@ -0,0 +1,4 @@ +// simple empty view, perfect for putting in tests +export function TestView() { + return
; +} diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index e4a09f7e..b674fb9f 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -146,6 +146,8 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) { source={props.stream.streamUrl} type={props.stream.type} quality={props.stream.quality} + embedId={props.stream.embedId} + providerId={props.stream.providerId} /> (null); const lastSearchValue = useRef<(string | undefined)[] | null>(null); diff --git a/src/views/search/HomeView.tsx b/src/views/search/HomeView.tsx index 60e213ad..952d1ec2 100644 --- a/src/views/search/HomeView.tsx +++ b/src/views/search/HomeView.tsx @@ -9,7 +9,7 @@ import { import { useWatchedContext } from "@/state/watched"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { EditButton } from "@/components/buttons/EditButton"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useHistory } from "react-router-dom"; import { Modal, ModalCard } from "@/components/layout/Modal"; @@ -22,6 +22,22 @@ function Bookmarks() { const bookmarks = getFilteredBookmarks(); const [editing, setEditing] = useState(false); const [gridRef] = useAutoAnimate(); + const { watched } = useWatchedContext(); + + const bookmarksSorted = useMemo(() => { + return bookmarks + .map((v) => { + return { + ...v, + watched: watched.items + .sort((a, b) => b.watchedAt - a.watchedAt) + .find((watchedItem) => watchedItem.item.meta.id === v.id), + }; + }) + .sort( + (a, b) => (b.watched?.watchedAt || 0) - (a.watched?.watchedAt || 0) + ); + }, [watched.items, bookmarks]); if (bookmarks.length === 0) return null; @@ -34,7 +50,7 @@ function Bookmarks() { - {bookmarks.map((v) => ( + {bookmarksSorted.map((v) => ( { + localStorage.setItem("mw-show-domain-modal", "false"); + setShow(false); + }, []); + useEffect(() => { const newParams = new URLSearchParams(history.location.search); newParams.delete("migrated"); + if (newParams.get("migrated") === "1") + localStorage.setItem("mw-show-domain-modal", "true"); history.replace({ search: newParams.toString(), }); @@ -161,7 +185,7 @@ function NewDomainModal() {

{t("v3.tireless")}

-
diff --git a/yarn.lock b/yarn.lock index 551fa234..c6cd52f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -927,10 +927,10 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" -"@esbuild/darwin-arm64@0.16.5": +"@esbuild/linux-x64@0.16.5": version "0.16.5" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.5.tgz" - integrity sha512-4HlbUMy50cRaHGVriBjShs46WRPshtnVOqkxEGhEuDuJhgZ3regpWzaQxXOcDXFvVwue8RiqDAAcOi/QlVLE6Q== + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.5.tgz" + integrity sha512-vsOwzKN+4NenUTyuoWLmg5dAuO8JKuLD9MXSeENA385XucuOZbblmOMwwgPlHsgVRtSjz38riqPJU2ALI/CWYQ== "@eslint/eslintrc@^1.3.3": version "1.3.3" @@ -948,9 +948,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" @@ -1056,6 +1056,52 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@react-spring/animated@~9.7.1": + version "9.7.1" + resolved "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.1.tgz" + integrity sha512-EX5KAD9y7sD43TnLeTNG1MgUVpuRO1YaSJRPawHNRgUWYfILge3s85anny4S4eTJGpdp5OoFV2kx9fsfeo0qsw== + dependencies: + "@react-spring/shared" "~9.7.1" + "@react-spring/types" "~9.7.1" + +"@react-spring/core@~9.7.1": + version "9.7.1" + resolved "https://registry.npmjs.org/@react-spring/core/-/core-9.7.1.tgz" + integrity sha512-8K9/FaRn5VvMa24mbwYxwkALnAAyMRdmQXrARZLcBW2vxLJ6uw9Cy3d06Z8M12kEqF2bDlccaCSDsn2bSz+Q4A== + dependencies: + "@react-spring/animated" "~9.7.1" + "@react-spring/rafz" "~9.7.1" + "@react-spring/shared" "~9.7.1" + "@react-spring/types" "~9.7.1" + +"@react-spring/rafz@~9.7.1": + version "9.7.1" + resolved "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.1.tgz" + integrity sha512-JSsrRfbEJvuE3w/uvU3mCTuWwpQcBXkwoW14lBgzK9XJhuxmscGo59AgJUpFkGOiGAVXFBGB+nEXtSinFsopgw== + +"@react-spring/shared@~9.7.1": + version "9.7.1" + resolved "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.1.tgz" + integrity sha512-R2kZ+VOO6IBeIAYTIA3C1XZ0ZVg/dDP5FKtWaY8k5akMer9iqf5H9BU0jyt3Qtxn0qQY7whQdf6MTcWtKeaawg== + dependencies: + "@react-spring/rafz" "~9.7.1" + "@react-spring/types" "~9.7.1" + +"@react-spring/types@~9.7.1": + version "9.7.1" + resolved "https://registry.npmjs.org/@react-spring/types/-/types-9.7.1.tgz" + integrity sha512-yBcyfKUeZv9wf/ZFrQszvhSPuDx6Py6yMJzpMnS+zxcZmhXPeOCKZSHwqrUz1WxvuRckUhlgb7eNI/x5e1e8CA== + +"@react-spring/web@^9.7.1": + version "9.7.1" + resolved "https://registry.npmjs.org/@react-spring/web/-/web-9.7.1.tgz" + integrity sha512-6uUE5MyKqdrJnIJqlDN/AXf3i8PjOQzUuT26nkpsYxUGOk7c+vZVPcfrExLSoKzTb9kF0i66DcqzO5fXz/Z1AA== + dependencies: + "@react-spring/animated" "~9.7.1" + "@react-spring/core" "~9.7.1" + "@react-spring/shared" "~9.7.1" + "@react-spring/types" "~9.7.1" + "@rollup/plugin-babel@^5.2.0": version "5.3.1" resolved "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz" @@ -1120,10 +1166,15 @@ magic-string "^0.25.0" string.prototype.matchall "^4.0.6" -"@swc/core-darwin-arm64@1.3.22": +"@swc/core-linux-x64-gnu@1.3.22": version "1.3.22" - resolved "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.22.tgz" - integrity sha512-MMhtPsuXp8gpUgr9bs+RZQ2IyFGiUNDG93usCDAFgAF+6VVp+YaAVjET/3/Bx5Lk2WAt0RxT62C9KTEw1YMo3w== + resolved "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.22.tgz" + integrity sha512-FLkbiqsdXsVIFZi6iedx4rSBGX8x0vo/5aDlklSxJAAYOcQpO0QADKP5Yr65iMT1d6ABCt2d+/StpGLF7GWOcA== + +"@swc/core-linux-x64-musl@1.3.22": + version "1.3.22" + resolved "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.22.tgz" + integrity sha512-giBuw+Z0Bq6fpZ0Y5TcfpcQwf9p/cE1fOQyO/K1XSTn/haQOqFi7421Jq/dFThSARZiXw1u9Om9VFbwxr8VI+A== "@swc/core@^1.3.21": version "1.3.22" @@ -1164,9 +1215,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" "*" @@ -1421,6 +1472,18 @@ "@typescript-eslint/types" "5.46.1" eslint-visitor-keys "^3.3.0" +"@use-gesture/core@10.2.24": + version "10.2.24" + resolved "https://registry.npmjs.org/@use-gesture/core/-/core-10.2.24.tgz" + integrity sha512-ZL7F9mgOn3Qlnp6QLI9jaOfcvqrx6JPE/BkdVSd8imveaFTm/a3udoO6f5Us/1XtqnL4347PsIiK6AtCvMHk2Q== + +"@use-gesture/react@^10.2.24": + version "10.2.24" + resolved "https://registry.npmjs.org/@use-gesture/react/-/react-10.2.24.tgz" + integrity sha512-rAZ8Nnpu1g4eFzqCPlaq+TppJpMy0dTpYOQx5KpfoBF4P3aWnCqwj7eKxcmdIb1NJKpIJj50DPugUH4mq5cpBg== + dependencies: + "@use-gesture/core" "10.2.24" + "@vitejs/plugin-react-swc@^3.0.0": version "3.0.0" resolved "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.0.0.tgz" @@ -1769,6 +1832,13 @@ buffer-from@^1.0.0: resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +bufferutil@^4.0.1: + version "4.0.7" + resolved "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz" + integrity sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw== + dependencies: + node-gyp-build "^4.3.0" + builtin-modules@^3.1.0: version "3.3.0" resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz" @@ -1798,9 +1868,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" @@ -1939,9 +2009,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" @@ -1951,9 +2021,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" @@ -2091,7 +2161,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== @@ -2686,11 +2756,6 @@ fscreen@^1.2.0: resolved "https://registry.npmjs.org/fscreen/-/fscreen-1.2.0.tgz" integrity sha512-hlq4+BU0hlPmwsFjwGGzZ+OZ9N/wq9Ljg/sq3pX+2CD7hrJsX9tJgWWK/wiNTFM212CLHWhicOoqwXyZGGetJg== -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - function-bind@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" @@ -3252,9 +3317,9 @@ json-stable-stringify-without-jsonify@^1.0.1: integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== json5@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz" - integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + version "1.0.2" + resolved "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== dependencies: minimist "^1.2.0" @@ -3527,10 +3592,15 @@ 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-gyp-build@^4.3.0: + version "4.6.0" + resolved "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz" + integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ== node-releases@^2.0.8: version "2.0.10" @@ -3625,13 +3695,13 @@ 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" @@ -3922,7 +3992,7 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -react-dom@*, "react-dom@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom@^16 || ^17 || ^18", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", react-dom@^17.0.2, react-dom@>=16.6.0: +react-dom@*, "react-dom@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom@^16 || ^17 || ^18", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", react-dom@^17.0.2, react-dom@>=16.6.0: version "17.0.2" resolved "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz" integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== @@ -4048,7 +4118,7 @@ react-use@^17.4.0: ts-easing "^0.2.0" tslib "^2.1.0" -react@*, "react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^16.3.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0", react@^17.0.2, "react@>= 16.8.0", react@>=15, react@>=16.3.0, react@>=16.6.0, react@17.0.2: +react@*, "react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^16.3.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0", react@^17.0.2, "react@>= 16.8.0", react@>=15, react@>=16.3.0, react@>=16.6.0, react@17.0.2: version "17.0.2" resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz" integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== @@ -4746,7 +4816,7 @@ typescript@*, typescript@^4.6.4, "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0 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== @@ -4834,6 +4904,13 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +utf-8-validate@>=5.0.2: + version "5.0.10" + resolved "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz" + integrity sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ== + dependencies: + node-gyp-build "^4.3.0" + util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"