diff --git a/package.json b/package.json index 43c72af0..f645b605 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "dependencies": { "@formkit/auto-animate": "^1.0.0-beta.5", "@headlessui/react": "^1.5.0", + "@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", @@ -63,7 +65,6 @@ "@types/pako": "^2.0.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.11", - "@types/react-helmet": "^6.1.6", "@types/react-router": "^5.1.18", "@types/react-router-dom": "^5.3.3", "@types/react-stickynode": "^4.0.0", diff --git a/src/backend/helpers/streams.ts b/src/backend/helpers/streams.ts index 92943d94..d5985cfa 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", 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 31c09b16..ae33aad7 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -36,7 +36,8 @@ export enum Icons { CASTING = "casting", CIRCLE_EXCLAMATION = "circle_exclamation", DOWNLOAD = "download", - SETTINGS = "settings", + GEAR = "gear", + WATCH_PARTY = "watch_party", PICTURE_IN_PICTURE = "pictureInPicture", } @@ -76,12 +77,13 @@ const iconList: Record = { skip_forward: ``, skip_backward: ``, file: ``, - captions: ``, + captions: ``, link: ``, circle_exclamation: ``, casting: "", download: ``, - settings: ``, + 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 7b197cfd..57428a8c 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -15,6 +15,7 @@ import { EmbedTesterView } from "@/views/developer/EmbedTesterView"; import { SettingsView } from "@/views/settings/SettingsView"; import { BannerContextProvider } from "@/hooks/useBanner"; import { Layout } from "@/setup/Layout"; +import { TestView } from "@/views/developer/TestView"; function App() { return ( @@ -46,6 +47,7 @@ function App() { {/* other */} +
- - - - + +
@@ -157,15 +153,12 @@ export function VideoPlayer(props: Props) { <>
- - -
+ + - - )} diff --git a/src/video/components/VideoPlayerBase.tsx b/src/video/components/VideoPlayerBase.tsx index 954bf0d1..57549729 100644 --- a/src/video/components/VideoPlayerBase.tsx +++ b/src/video/components/VideoPlayerBase.tsx @@ -27,7 +27,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 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/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/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 c66fe12a..c8f14f14 100644 --- a/src/video/components/popouts/CaptionSelectionPopout.tsx +++ b/src/video/components/popouts/CaptionSelectionPopout.tsx @@ -6,6 +6,9 @@ import { import { MWCaption } from "@/backend/helpers/streams"; import { IconButton } from "@/components/buttons/IconButton"; 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"; @@ -20,7 +23,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(); @@ -69,77 +75,73 @@ export function CaptionSelectionPopout() { const [showCaptionSettings, setShowCaptionSettings] = useState(false); return ( - <> - -
{t("videoPlayer.popouts.captions")}
- { - setShowCaptionSettings((old) => !old); - }} - /> -
- {showCaptionSettings ? ( - - ) : ( -
- - { - controls.clearCaption(); - controls.closePopout(); - }} - > - {t("videoPlayer.popouts.noCaptions")} - - { - customCaptionUploadElement.current?.click(); - }} - > - {currentCaption === CUSTOM_CAPTION_ID - ? t("videoPlayer.popouts.customCaption") - : t("videoPlayer.popouts.uploadCustomCaption")} - - - + + props.router.navigate("/")} + /> + + + { + controls.clearCaption(); + controls.closePopout(); + }} + > + {t("videoPlayer.popouts.noCaptions")} + + { + customCaptionUploadElement.current?.click(); + }} + > + {currentCaption === CUSTOM_CAPTION_ID + ? t("videoPlayer.popouts.customCaption") + : t("videoPlayer.popouts.uploadCustomCaption")} + + + -

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

+

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

- -
- {linkedCaptions.map((link) => ( - { - loadingId.current = link.id; - setCaption(link, true); - }} - > - {link.langIso} - - ))} -
-
-
- )} - + +
+ {linkedCaptions.map((link) => ( + { + loadingId.current = link.id; + setCaption(link, true); + }} + > + {link.langIso} + + ))} +
+
+ + ); } 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..9651b306 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,7 +15,10 @@ 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 { PopoutListEntry } from "./PopoutUtils"; interface EmbedEntryProps { name: string; @@ -49,7 +52,10 @@ export function EmbedEntry(props: EmbedEntryProps) { ); } -export function SourceSelectionPopout() { +export function SourceSelectionPopout(props: { + router: ReturnType; + prefix: string; +}) { const { t } = useTranslation(); const descriptor = useVideoPlayerDescriptor(); @@ -66,7 +72,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] @@ -106,6 +111,7 @@ export function SourceSelectionPopout() { if (!providerId) { providerRef.current = null; setSelectedProvider(null); + props.router.navigate(`/${props.prefix}/source`); return; } @@ -135,16 +141,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 +173,43 @@ 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 ? (
@@ -268,22 +265,8 @@ export function SourceSelectionPopout() { )} )} - - -
- {providers.map((v) => ( - { - selectProvider(v.id); - }} - > - {v.displayName} - - ))} -
-
-
+
+
); } 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/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 bf5ed12c..149c6895 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1152,6 +1152,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" @@ -1417,13 +1463,6 @@ dependencies: "@types/react" "^17" -"@types/react-helmet@^6.1.6": - version "6.1.6" - resolved "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.6.tgz" - integrity sha512-ZKcoOdW/Tg+kiUbkFCBtvDw0k3nD4HJ/h/B9yWxN4uDO8OkRksWTO+EL+z/Qu3aHTeTll3Ro0Cc/8UhwBCMG5A== - dependencies: - "@types/react" "*" - "@types/react-router-dom@^5.3.3": version "5.3.3" resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz" @@ -1569,6 +1608,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"