mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-25 21:01:53 +01:00
banners in video player
This commit is contained in:
parent
294f31c567
commit
b5dae824c8
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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]);
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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,12 +57,12 @@ function QuickSearch() {
|
||||
|
||||
function App() {
|
||||
useHistoryListener();
|
||||
useOnlineListener();
|
||||
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<WatchedContextProvider>
|
||||
<BookmarkContextProvider>
|
||||
<BannerContextProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{/* functional routes */}
|
||||
@ -126,7 +126,6 @@ function App() {
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
</BannerContextProvider>
|
||||
</BookmarkContextProvider>
|
||||
</WatchedContextProvider>
|
||||
</SettingsProvider>
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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(),
|
||||
],
|
||||
});
|
63
src/stores/banner/BannerLocation.tsx
Normal file
63
src/stores/banner/BannerLocation.tsx
Normal 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>
|
||||
);
|
||||
}
|
88
src/stores/banner/index.ts
Normal file
88
src/stores/banner/index.ts
Normal 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];
|
||||
}
|
Loading…
Reference in New Issue
Block a user