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

View File

@ -4,8 +4,8 @@ import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Lightbar } from "@/components/utils/Lightbar";
import { useBannerSize } from "@/hooks/useBanner";
import { conf } from "@/setup/config";
import { useBannerSize } from "@/stores/banner";
import { BrandPill } from "./BrandPill";
@ -20,27 +20,32 @@ export function Navigation(props: NavigationProps) {
return (
<>
{!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">
<Lightbar />
</div>
</div>
) : null}
<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={{
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
className={`${
props.bg ? "opacity-100" : "opacity-0"
} 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 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="/">
<BrandPill clickable />
</Link>

View File

@ -1,12 +1,15 @@
import { useEffect } from "react";
import { Transition } from "@/components/Transition";
import { useBannerSize } from "@/stores/banner";
import { BannerLocation } from "@/stores/banner/BannerLocation";
import { usePlayerStore } from "@/stores/player/store";
export function TopControls(props: {
show?: boolean;
children: React.ReactNode;
}) {
const bannerSize = useBannerSize("player");
const setHoveringAnyControls = usePlayerStore(
(s) => s.setHoveringAnyControls
);
@ -22,12 +25,21 @@ export function TopControls(props: {
<Transition
animation="fade"
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"
/>
<div className="relative z-10">
<BannerLocation location="player" />
</div>
<div
onMouseOver={() => setHoveringAnyControls(true)}
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"
style={{
top: `${bannerSize}px`,
}}
>
<Transition
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() {
const [online, setOnline] = useState<boolean | null>(true);
import { useBannerStore } from "@/stores/banner";
export function useOnlineListener() {
const updateOnline = useBannerStore((s) => s.updateOnline);
const ref = useRef<boolean>(true);
useEffect(() => {
@ -21,12 +23,12 @@ export function useIsOnline() {
const signal = abort.signal;
fetch("/ping.txt", { signal })
.then(() => {
setOnline(true);
updateOnline(true);
ref.current = true;
})
.catch((err) => {
if (err.name === "AbortError") return;
setOnline(false);
updateOnline(false);
ref.current = false;
});
}, 5000);
@ -35,7 +37,5 @@ export function useIsOnline() {
clearInterval(interval);
if (abort) abort.abort();
};
}, []);
return online;
}, [updateOnline]);
}

View File

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

View File

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

View File

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

View File

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

View File

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