banners in video player

This commit is contained in:
mrjvs 2023-10-21 21:44:08 +02:00
parent 294f31c567
commit b5dae824c8
15 changed files with 257 additions and 207 deletions

View File

@ -1,28 +0,0 @@
import { Icon, Icons } from "@/components/Icon";
import { useBanner } from "@/hooks/useBanner";
export function Banner(props: { children: React.ReactNode; type: "error" }) {
const [ref] = useBanner<HTMLDivElement>("internet");
const styles = {
error: "bg-[#C93957] text-white",
};
const icons = {
error: Icons.CIRCLE_EXCLAMATION,
};
return (
<div ref={ref}>
<div
className={[
styles[props.type],
"flex items-center justify-center p-1",
].join(" ")}
>
<div className="flex items-center space-x-3">
<Icon icon={icons[props.type]} />
<div>{props.children}</div>
</div>
</div>
</div>
);
}

View File

@ -2,7 +2,7 @@ import {
Transition as HeadlessTransition, Transition as HeadlessTransition,
TransitionClasses, TransitionClasses,
} from "@headlessui/react"; } from "@headlessui/react";
import { Fragment, ReactNode } from "react"; import { CSSProperties, Fragment, ReactNode } from "react";
export type TransitionAnimations = export type TransitionAnimations =
| "slide-down" | "slide-down"
@ -19,6 +19,7 @@ interface Props {
className?: string; className?: string;
children?: ReactNode; children?: ReactNode;
isChild?: boolean; isChild?: boolean;
style?: CSSProperties;
} }
function getClasses( function getClasses(
@ -90,14 +91,18 @@ export function Transition(props: Props) {
if (props.isChild) { if (props.isChild) {
return ( return (
<HeadlessTransition.Child as={Fragment} {...classes}> <HeadlessTransition.Child as={Fragment} {...classes}>
<div className={props.className}>{props.children}</div> <div className={props.className} style={props.style}>
{props.children}
</div>
</HeadlessTransition.Child> </HeadlessTransition.Child>
); );
} }
return ( return (
<HeadlessTransition show={props.show} as={Fragment} {...classes}> <HeadlessTransition show={props.show} as={Fragment} {...classes}>
<div className={props.className}>{props.children}</div> <div className={props.className} style={props.style}>
{props.children}
</div>
</HeadlessTransition> </HeadlessTransition>
); );
} }

View File

@ -4,8 +4,8 @@ import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch"; import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { Lightbar } from "@/components/utils/Lightbar"; import { Lightbar } from "@/components/utils/Lightbar";
import { useBannerSize } from "@/hooks/useBanner";
import { conf } from "@/setup/config"; import { conf } from "@/setup/config";
import { useBannerSize } from "@/stores/banner";
import { BrandPill } from "./BrandPill"; import { BrandPill } from "./BrandPill";
@ -20,27 +20,32 @@ export function Navigation(props: NavigationProps) {
return ( return (
<> <>
{!props.noLightbar ? ( {!props.noLightbar ? (
<div className="absolute inset-x-0 top-0 flex h-[88px] items-center justify-center"> <div
className="absolute inset-x-0 top-0 flex h-[88px] items-center justify-center"
style={{
top: `${bannerHeight}px`,
}}
>
<div className="absolute inset-x-0 -mt-[22%] flex items-center sm:mt-0"> <div className="absolute inset-x-0 -mt-[22%] flex items-center sm:mt-0">
<Lightbar /> <Lightbar />
</div> </div>
</div> </div>
) : null} ) : null}
<div <div
className="fixed left-0 right-0 top-0 z-10 min-h-[150px]" className="fixed pointer-events-none left-0 right-0 top-0 z-10 min-h-[150px]"
style={{ style={{
top: `${bannerHeight}px`, top: `${bannerHeight}px`,
}} }}
> >
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5"> <div className="fixed left-0 right-0 flex items-center">
<div <div
className={`${ className={`${
props.bg ? "opacity-100" : "opacity-0" props.bg ? "opacity-100" : "opacity-0"
} absolute inset-0 block bg-background-main transition-opacity duration-300`} } absolute inset-0 block bg-background-main transition-opacity duration-300`}
> >
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" /> <div className="absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" />
</div> </div>
<div className="relative flex w-full items-center sm:w-fit space-x-3"> <div className="pointer-events-auto px-7 py-5 relative flex flex-1 items-center space-x-3">
<Link className="block" to="/"> <Link className="block" to="/">
<BrandPill clickable /> <BrandPill clickable />
</Link> </Link>

View File

@ -1,12 +1,15 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
import { useBannerSize } from "@/stores/banner";
import { BannerLocation } from "@/stores/banner/BannerLocation";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
export function TopControls(props: { export function TopControls(props: {
show?: boolean; show?: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const bannerSize = useBannerSize("player");
const setHoveringAnyControls = usePlayerStore( const setHoveringAnyControls = usePlayerStore(
(s) => s.setHoveringAnyControls (s) => s.setHoveringAnyControls
); );
@ -22,12 +25,21 @@ export function TopControls(props: {
<Transition <Transition
animation="fade" animation="fade"
show={props.show} show={props.show}
style={{
top: `${bannerSize}px`,
}}
className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full" className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full"
/> />
<div className="relative z-10">
<BannerLocation location="player" />
</div>
<div <div
onMouseOver={() => setHoveringAnyControls(true)} onMouseOver={() => setHoveringAnyControls(true)}
onMouseOut={() => setHoveringAnyControls(false)} onMouseOut={() => setHoveringAnyControls(false)}
className="pointer-events-auto pl-[calc(2rem+env(safe-area-inset-left))] pr-[calc(2rem+env(safe-area-inset-right))] pt-6 absolute top-0 w-full" className="pointer-events-auto pl-[calc(2rem+env(safe-area-inset-left))] pr-[calc(2rem+env(safe-area-inset-right))] pt-6 absolute top-0 w-full"
style={{
top: `${bannerSize}px`,
}}
> >
<Transition <Transition
animation="slide-down" animation="slide-down"

View File

@ -1,61 +0,0 @@
import {
Dispatch,
ReactNode,
SetStateAction,
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useMeasure } from "react-use";
interface BannerInstance {
id: string;
height: number;
}
const BannerContext = createContext<
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>]
>(null as any);
export function BannerContextProvider(props: { children: ReactNode }) {
const [state, setState] = useState<BannerInstance[]>([]);
const memod = useMemo<
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>]
>(() => [state, setState], [state]);
return (
<BannerContext.Provider value={memod}>
{props.children}
</BannerContext.Provider>
);
}
export function useBanner<T extends Element>(id: string) {
const [ref, { height }] = useMeasure<T>();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, set] = useContext(BannerContext);
useEffect(() => {
set((v) => [...v, { id, height: 0 }]);
set((value) => {
const v = value.find((item) => item.id === id);
if (v) {
v.height = height;
}
return value;
});
return () => {
set((v) => v.filter((item) => item.id !== id));
};
}, [height, id, set]);
return [ref];
}
export function useBannerSize() {
const [val] = useContext(BannerContext);
return val.reduce((a, v) => a + v.height, 0);
}

View File

@ -1,12 +0,0 @@
import { useCallback } from "react";
import { useHistory } from "react-router-dom";
export function useGoBack() {
const reactHistory = useHistory();
const goBack = useCallback(() => {
if (reactHistory.action !== "POP") reactHistory.goBack();
else reactHistory.push("/");
}, [reactHistory]);
return goBack;
}

View File

@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
export function useIsOnline() { import { useBannerStore } from "@/stores/banner";
const [online, setOnline] = useState<boolean | null>(true);
export function useOnlineListener() {
const updateOnline = useBannerStore((s) => s.updateOnline);
const ref = useRef<boolean>(true); const ref = useRef<boolean>(true);
useEffect(() => { useEffect(() => {
@ -21,12 +23,12 @@ export function useIsOnline() {
const signal = abort.signal; const signal = abort.signal;
fetch("/ping.txt", { signal }) fetch("/ping.txt", { signal })
.then(() => { .then(() => {
setOnline(true); updateOnline(true);
ref.current = true; ref.current = true;
}) })
.catch((err) => { .catch((err) => {
if (err.name === "AbortError") return; if (err.name === "AbortError") return;
setOnline(false); updateOnline(false);
ref.current = false; ref.current = false;
}); });
}, 5000); }, 5000);
@ -35,7 +37,5 @@ export function useIsOnline() {
clearInterval(interval); clearInterval(interval);
if (abort) abort.abort(); if (abort) abort.abort();
}; };
}, []); }, [updateOnline]);
return online;
} }

View File

@ -12,7 +12,6 @@ import { assertConfig, conf } from "@/setup/config";
import i18n from "@/setup/i18n"; import i18n from "@/setup/i18n";
import "@/setup/ga"; import "@/setup/ga";
import "@/setup/sentry";
import "@/setup/index.css"; import "@/setup/index.css";
import { initializeChromecast } from "./setup/chromecast"; import { initializeChromecast } from "./setup/chromecast";
import { SettingsStore } from "./state/settings/store"; import { SettingsStore } from "./state/settings/store";

View File

@ -40,7 +40,7 @@ export function HomePage() {
return ( return (
<HomeLayout showBg={showBg}> <HomeLayout showBg={showBg}>
<div className="relative z-10 mb-16 sm:mb-24"> <div className="mb-16 sm:mb-24">
<Helmet> <Helmet>
<title>{t("global.name")}</title> <title>{t("global.name")}</title>
</Helmet> </Helmet>

View File

@ -5,8 +5,8 @@ import Sticky from "react-stickynode";
import { ThinContainer } from "@/components/layout/ThinContainer"; import { ThinContainer } from "@/components/layout/ThinContainer";
import { SearchBarInput } from "@/components/SearchBar"; import { SearchBarInput } from "@/components/SearchBar";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { useBannerSize } from "@/hooks/useBanner";
import { useSearchQuery } from "@/hooks/useSearchQuery"; import { useSearchQuery } from "@/hooks/useSearchQuery";
import { useBannerSize } from "@/stores/banner";
export interface HeroPartProps { export interface HeroPartProps {
setIsSticky: (val: boolean) => void; setIsSticky: (val: boolean) => void;

View File

@ -10,7 +10,7 @@ import {
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
import { BannerContextProvider } from "@/hooks/useBanner"; import { useOnlineListener } from "@/hooks/usePing";
import { AboutPage } from "@/pages/About"; import { AboutPage } from "@/pages/About";
import { DmcaPage } from "@/pages/Dmca"; import { DmcaPage } from "@/pages/Dmca";
import { NotFoundPage } from "@/pages/errors/NotFoundPage"; import { NotFoundPage } from "@/pages/errors/NotFoundPage";
@ -57,76 +57,75 @@ function QuickSearch() {
function App() { function App() {
useHistoryListener(); useHistoryListener();
useOnlineListener();
return ( return (
<SettingsProvider> <SettingsProvider>
<WatchedContextProvider> <WatchedContextProvider>
<BookmarkContextProvider> <BookmarkContextProvider>
<BannerContextProvider> <Layout>
<Layout> <Switch>
<Switch> {/* functional routes */}
{/* functional routes */} <Route exact path="/s/:query">
<Route exact path="/s/:query"> <QuickSearch />
<QuickSearch /> </Route>
</Route> <Route exact path="/search/:type">
<Route exact path="/search/:type"> <Redirect to="/browse" push={false} />
<Redirect to="/browse" push={false} /> </Route>
</Route> <Route exact path="/search/:type/:query?">
<Route exact path="/search/:type/:query?"> {({ match }) => {
{({ match }) => { if (match?.params.query)
if (match?.params.query) return (
return ( <Redirect
<Redirect to={`/browse/${match?.params.query}`}
to={`/browse/${match?.params.query}`} push={false}
push={false} />
/> );
); return <Redirect to="/browse" push={false} />;
return <Redirect to="/browse" push={false} />; }}
}} </Route>
</Route>
{/* pages */} {/* pages */}
<Route <Route
exact exact
path={["/media/:media", "/media/:media/:season/:episode"]} path={["/media/:media", "/media/:media/:season/:episode"]}
> >
<LegacyUrlView> <LegacyUrlView>
<PlayerView /> <PlayerView />
</LegacyUrlView> </LegacyUrlView>
</Route> </Route>
<Route <Route
exact exact
path={["/browse/:query?", "/"]} path={["/browse/:query?", "/"]}
component={HomePage} component={HomePage}
/> />
<Route exact path="/faq" component={AboutPage} /> <Route exact path="/faq" component={AboutPage} />
<Route exact path="/dmca" component={DmcaPage} /> <Route exact path="/dmca" component={DmcaPage} />
{/* other */} {/* other */}
<Route
exact
path="/dev"
component={lazy(() => import("@/pages/DeveloperPage"))}
/>
<Route
exact
path="/dev/video"
component={lazy(
() => import("@/pages/developer/VideoTesterView")
)}
/>
{/* developer routes that can abuse workers are disabled in production */}
{process.env.NODE_ENV === "development" ? (
<Route <Route
exact exact
path="/dev" path="/dev/test"
component={lazy(() => import("@/pages/DeveloperPage"))} component={lazy(() => import("@/pages/developer/TestView"))}
/> />
<Route ) : null}
exact <Route path="*" component={NotFoundPage} />
path="/dev/video" </Switch>
component={lazy( </Layout>
() => import("@/pages/developer/VideoTesterView")
)}
/>
{/* developer routes that can abuse workers are disabled in production */}
{process.env.NODE_ENV === "development" ? (
<Route
exact
path="/dev/test"
component={lazy(() => import("@/pages/developer/TestView"))}
/>
) : null}
<Route path="*" component={NotFoundPage} />
</Switch>
</Layout>
</BannerContextProvider>
</BookmarkContextProvider> </BookmarkContextProvider>
</WatchedContextProvider> </WatchedContextProvider>
</SettingsProvider> </SettingsProvider>

View File

@ -1,23 +1,20 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Banner } from "@/components/Banner"; import { useBannerSize, useBannerStore } from "@/stores/banner";
import { useBannerSize } from "@/hooks/useBanner"; import { BannerLocation } from "@/stores/banner/BannerLocation";
import { useIsOnline } from "@/hooks/usePing";
export function Layout(props: { children: ReactNode }) { export function Layout(props: { children: ReactNode }) {
const { t } = useTranslation();
const isOnline = useIsOnline();
const bannerSize = useBannerSize(); const bannerSize = useBannerSize();
const location = useBannerStore((s) => s.location);
return ( return (
<div> <div>
<div className="fixed inset-x-0 z-[1000]"> <div className="fixed inset-x-0 z-[1000]">
{!isOnline ? <Banner type="error">{t("errors.offline")}</Banner> : null} <BannerLocation />
</div> </div>
<div <div
style={{ style={{
paddingTop: `${bannerSize}px`, paddingTop: location === null ? `${bannerSize}px` : "0px",
}} }}
className="flex min-h-screen flex-col" className="flex min-h-screen flex-col"
> >

View File

@ -1,17 +0,0 @@
import { CaptureConsole, HttpClient } from "@sentry/integrations";
import * as Sentry from "@sentry/react";
import { conf } from "@/setup/config";
import { SENTRY_DSN } from "@/setup/constants";
if (process.env.NODE_ENV !== "development")
Sentry.init({
dsn: SENTRY_DSN,
release: `movie-web@${conf().APP_VERSION}`,
sampleRate: 0.5,
integrations: [
new Sentry.BrowserTracing(),
new CaptureConsole(),
new HttpClient(),
],
});

View File

@ -0,0 +1,63 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { useBannerStore, useRegisterBanner } from "@/stores/banner";
export function Banner(props: {
children: React.ReactNode;
type: "error";
id: string;
}) {
const [ref] = useRegisterBanner<HTMLDivElement>(props.id);
const styles = {
error: "bg-[#C93957] text-white",
};
const icons = {
error: Icons.CIRCLE_EXCLAMATION,
};
return (
<div ref={ref}>
<div
className={[
styles[props.type],
"flex items-center justify-center p-1",
].join(" ")}
>
<div className="flex items-center space-x-3">
<Icon icon={icons[props.type]} />
<div>{props.children}</div>
</div>
</div>
</div>
);
}
export function BannerLocation(props: { location?: string }) {
const { t } = useTranslation();
const isOnline = useBannerStore((s) => s.isOnline);
const setLocation = useBannerStore((s) => s.setLocation);
const currentLocation = useBannerStore((s) => s.location);
const loc = props.location ?? null;
useEffect(() => {
if (!loc) return;
setLocation(loc);
return () => {
setLocation(null);
};
}, [setLocation, loc]);
if (currentLocation !== loc) return null;
return (
<div>
{!isOnline ? (
<Banner id="offline" type="error">
{t("errors.offline")}
</Banner>
) : null}
</div>
);
}

View File

@ -0,0 +1,88 @@
import { useEffect } from "react";
import { useMeasure } from "react-use";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
interface BannerInstance {
id: string;
height: number;
}
interface BannerStore {
banners: BannerInstance[];
isOnline: boolean;
location: string | null;
updateHeight(id: string, height: number): void;
showBanner(id: string): void;
hideBanner(id: string): void;
setLocation(loc: string | null): void;
updateOnline(isOnline: boolean): void;
}
export const useBannerStore = create(
immer<BannerStore>((set) => ({
banners: [],
isOnline: true,
location: null,
updateOnline(isOnline) {
set((s) => {
s.isOnline = isOnline;
});
},
setLocation(loc) {
set((s) => {
s.location = loc;
});
},
showBanner(id) {
set((s) => {
if (s.banners.find((v) => v.id === id)) return;
s.banners.push({
id,
height: 0,
});
});
},
hideBanner(id) {
set((s) => {
s.banners = s.banners.filter((v) => v.id !== id);
});
},
updateHeight(id, height) {
set((s) => {
const found = s.banners.find((v) => v.id === id);
if (found) found.height = height;
});
},
}))
);
export function useBannerSize(location?: string) {
const loc = location ?? null;
const banners = useBannerStore((s) => s.banners);
const currentLocation = useBannerStore((s) => s.location);
const size = banners.reduce((a, v) => a + v.height, 0);
if (loc !== currentLocation) return 0;
return size;
}
export function useRegisterBanner<T extends Element>(id: string) {
const [ref, { height }] = useMeasure<T>();
const updateHeight = useBannerStore((s) => s.updateHeight);
const showBanner = useBannerStore((s) => s.showBanner);
const hideBanner = useBannerStore((s) => s.hideBanner);
useEffect(() => {
showBanner(id);
return () => {
hideBanner(id);
};
}, [showBanner, hideBanner, id]);
useEffect(() => {
updateHeight(id, height);
}, [height, id, updateHeight]);
return [ref];
}