pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/

banners in video player

mrjvs b5dae824 294f31c5

+257 -207
-28
src/components/Banner.tsx
··· 1 - import { Icon, Icons } from "@/components/Icon"; 2 - import { useBanner } from "@/hooks/useBanner"; 3 - 4 - export function Banner(props: { children: React.ReactNode; type: "error" }) { 5 - const [ref] = useBanner<HTMLDivElement>("internet"); 6 - const styles = { 7 - error: "bg-[#C93957] text-white", 8 - }; 9 - const icons = { 10 - error: Icons.CIRCLE_EXCLAMATION, 11 - }; 12 - 13 - return ( 14 - <div ref={ref}> 15 - <div 16 - className={[ 17 - styles[props.type], 18 - "flex items-center justify-center p-1", 19 - ].join(" ")} 20 - > 21 - <div className="flex items-center space-x-3"> 22 - <Icon icon={icons[props.type]} /> 23 - <div>{props.children}</div> 24 - </div> 25 - </div> 26 - </div> 27 - ); 28 - }
+8 -3
src/components/Transition.tsx
··· 2 2 Transition as HeadlessTransition, 3 3 TransitionClasses, 4 4 } from "@headlessui/react"; 5 - import { Fragment, ReactNode } from "react"; 5 + import { CSSProperties, Fragment, ReactNode } from "react"; 6 6 7 7 export type TransitionAnimations = 8 8 | "slide-down" ··· 19 19 className?: string; 20 20 children?: ReactNode; 21 21 isChild?: boolean; 22 + style?: CSSProperties; 22 23 } 23 24 24 25 function getClasses( ··· 90 91 if (props.isChild) { 91 92 return ( 92 93 <HeadlessTransition.Child as={Fragment} {...classes}> 93 - <div className={props.className}>{props.children}</div> 94 + <div className={props.className} style={props.style}> 95 + {props.children} 96 + </div> 94 97 </HeadlessTransition.Child> 95 98 ); 96 99 } 97 100 98 101 return ( 99 102 <HeadlessTransition show={props.show} as={Fragment} {...classes}> 100 - <div className={props.className}>{props.children}</div> 103 + <div className={props.className} style={props.style}> 104 + {props.children} 105 + </div> 101 106 </HeadlessTransition> 102 107 ); 103 108 }
+11 -6
src/components/layout/Navigation.tsx
··· 4 4 import { IconPatch } from "@/components/buttons/IconPatch"; 5 5 import { Icons } from "@/components/Icon"; 6 6 import { Lightbar } from "@/components/utils/Lightbar"; 7 - import { useBannerSize } from "@/hooks/useBanner"; 8 7 import { conf } from "@/setup/config"; 8 + import { useBannerSize } from "@/stores/banner"; 9 9 10 10 import { BrandPill } from "./BrandPill"; 11 11 ··· 20 20 return ( 21 21 <> 22 22 {!props.noLightbar ? ( 23 - <div className="absolute inset-x-0 top-0 flex h-[88px] items-center justify-center"> 23 + <div 24 + className="absolute inset-x-0 top-0 flex h-[88px] items-center justify-center" 25 + style={{ 26 + top: `${bannerHeight}px`, 27 + }} 28 + > 24 29 <div className="absolute inset-x-0 -mt-[22%] flex items-center sm:mt-0"> 25 30 <Lightbar /> 26 31 </div> 27 32 </div> 28 33 ) : null} 29 34 <div 30 - className="fixed left-0 right-0 top-0 z-10 min-h-[150px]" 35 + className="fixed pointer-events-none left-0 right-0 top-0 z-10 min-h-[150px]" 31 36 style={{ 32 37 top: `${bannerHeight}px`, 33 38 }} 34 39 > 35 - <div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5"> 40 + <div className="fixed left-0 right-0 flex items-center"> 36 41 <div 37 42 className={`${ 38 43 props.bg ? "opacity-100" : "opacity-0" 39 44 } absolute inset-0 block bg-background-main transition-opacity duration-300`} 40 45 > 41 - <div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" /> 46 + <div className="absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" /> 42 47 </div> 43 - <div className="relative flex w-full items-center sm:w-fit space-x-3"> 48 + <div className="pointer-events-auto px-7 py-5 relative flex flex-1 items-center space-x-3"> 44 49 <Link className="block" to="/"> 45 50 <BrandPill clickable /> 46 51 </Link>
+12
src/components/player/base/TopControls.tsx
··· 1 1 import { useEffect } from "react"; 2 2 3 3 import { Transition } from "@/components/Transition"; 4 + import { useBannerSize } from "@/stores/banner"; 5 + import { BannerLocation } from "@/stores/banner/BannerLocation"; 4 6 import { usePlayerStore } from "@/stores/player/store"; 5 7 6 8 export function TopControls(props: { 7 9 show?: boolean; 8 10 children: React.ReactNode; 9 11 }) { 12 + const bannerSize = useBannerSize("player"); 10 13 const setHoveringAnyControls = usePlayerStore( 11 14 (s) => s.setHoveringAnyControls 12 15 ); ··· 22 25 <Transition 23 26 animation="fade" 24 27 show={props.show} 28 + style={{ 29 + top: `${bannerSize}px`, 30 + }} 25 31 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" 26 32 /> 33 + <div className="relative z-10"> 34 + <BannerLocation location="player" /> 35 + </div> 27 36 <div 28 37 onMouseOver={() => setHoveringAnyControls(true)} 29 38 onMouseOut={() => setHoveringAnyControls(false)} 30 39 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" 40 + style={{ 41 + top: `${bannerSize}px`, 42 + }} 31 43 > 32 44 <Transition 33 45 animation="slide-down"
-61
src/hooks/useBanner.tsx
··· 1 - import { 2 - Dispatch, 3 - ReactNode, 4 - SetStateAction, 5 - createContext, 6 - useContext, 7 - useEffect, 8 - useMemo, 9 - useState, 10 - } from "react"; 11 - import { useMeasure } from "react-use"; 12 - 13 - interface BannerInstance { 14 - id: string; 15 - height: number; 16 - } 17 - 18 - const BannerContext = createContext< 19 - [BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>] 20 - >(null as any); 21 - 22 - export function BannerContextProvider(props: { children: ReactNode }) { 23 - const [state, setState] = useState<BannerInstance[]>([]); 24 - const memod = useMemo< 25 - [BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>] 26 - >(() => [state, setState], [state]); 27 - 28 - return ( 29 - <BannerContext.Provider value={memod}> 30 - {props.children} 31 - </BannerContext.Provider> 32 - ); 33 - } 34 - 35 - export function useBanner<T extends Element>(id: string) { 36 - const [ref, { height }] = useMeasure<T>(); 37 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 38 - const [_, set] = useContext(BannerContext); 39 - 40 - useEffect(() => { 41 - set((v) => [...v, { id, height: 0 }]); 42 - set((value) => { 43 - const v = value.find((item) => item.id === id); 44 - if (v) { 45 - v.height = height; 46 - } 47 - return value; 48 - }); 49 - return () => { 50 - set((v) => v.filter((item) => item.id !== id)); 51 - }; 52 - }, [height, id, set]); 53 - 54 - return [ref]; 55 - } 56 - 57 - export function useBannerSize() { 58 - const [val] = useContext(BannerContext); 59 - 60 - return val.reduce((a, v) => a + v.height, 0); 61 - }
-12
src/hooks/useGoBack.ts
··· 1 - import { useCallback } from "react"; 2 - import { useHistory } from "react-router-dom"; 3 - 4 - export function useGoBack() { 5 - const reactHistory = useHistory(); 6 - 7 - const goBack = useCallback(() => { 8 - if (reactHistory.action !== "POP") reactHistory.goBack(); 9 - else reactHistory.push("/"); 10 - }, [reactHistory]); 11 - return goBack; 12 - }
+8 -8
src/hooks/usePing.ts
··· 1 - import { useEffect, useRef, useState } from "react"; 1 + import { useEffect, useRef } from "react"; 2 2 3 - export function useIsOnline() { 4 - const [online, setOnline] = useState<boolean | null>(true); 3 + import { useBannerStore } from "@/stores/banner"; 4 + 5 + export function useOnlineListener() { 6 + const updateOnline = useBannerStore((s) => s.updateOnline); 5 7 const ref = useRef<boolean>(true); 6 8 7 9 useEffect(() => { ··· 21 23 const signal = abort.signal; 22 24 fetch("/ping.txt", { signal }) 23 25 .then(() => { 24 - setOnline(true); 26 + updateOnline(true); 25 27 ref.current = true; 26 28 }) 27 29 .catch((err) => { 28 30 if (err.name === "AbortError") return; 29 - setOnline(false); 31 + updateOnline(false); 30 32 ref.current = false; 31 33 }); 32 34 }, 5000); ··· 35 37 clearInterval(interval); 36 38 if (abort) abort.abort(); 37 39 }; 38 - }, []); 39 - 40 - return online; 40 + }, [updateOnline]); 41 41 }
-1
src/index.tsx
··· 12 12 import i18n from "@/setup/i18n"; 13 13 14 14 import "@/setup/ga"; 15 - import "@/setup/sentry"; 16 15 import "@/setup/index.css"; 17 16 import { initializeChromecast } from "./setup/chromecast"; 18 17 import { SettingsStore } from "./state/settings/store";
+1 -1
src/pages/HomePage.tsx
··· 40 40 41 41 return ( 42 42 <HomeLayout showBg={showBg}> 43 - <div className="relative z-10 mb-16 sm:mb-24"> 43 + <div className="mb-16 sm:mb-24"> 44 44 <Helmet> 45 45 <title>{t("global.name")}</title> 46 46 </Helmet>
+1 -1
src/pages/parts/home/HeroPart.tsx
··· 5 5 import { ThinContainer } from "@/components/layout/ThinContainer"; 6 6 import { SearchBarInput } from "@/components/SearchBar"; 7 7 import { Title } from "@/components/text/Title"; 8 - import { useBannerSize } from "@/hooks/useBanner"; 9 8 import { useSearchQuery } from "@/hooks/useSearchQuery"; 9 + import { useBannerSize } from "@/stores/banner"; 10 10 11 11 export interface HeroPartProps { 12 12 setIsSticky: (val: boolean) => void;
+60 -61
src/setup/App.tsx
··· 10 10 11 11 import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; 12 12 import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; 13 - import { BannerContextProvider } from "@/hooks/useBanner"; 13 + import { useOnlineListener } from "@/hooks/usePing"; 14 14 import { AboutPage } from "@/pages/About"; 15 15 import { DmcaPage } from "@/pages/Dmca"; 16 16 import { NotFoundPage } from "@/pages/errors/NotFoundPage"; ··· 57 57 58 58 function App() { 59 59 useHistoryListener(); 60 + useOnlineListener(); 60 61 61 62 return ( 62 63 <SettingsProvider> 63 64 <WatchedContextProvider> 64 65 <BookmarkContextProvider> 65 - <BannerContextProvider> 66 - <Layout> 67 - <Switch> 68 - {/* functional routes */} 69 - <Route exact path="/s/:query"> 70 - <QuickSearch /> 71 - </Route> 72 - <Route exact path="/search/:type"> 73 - <Redirect to="/browse" push={false} /> 74 - </Route> 75 - <Route exact path="/search/:type/:query?"> 76 - {({ match }) => { 77 - if (match?.params.query) 78 - return ( 79 - <Redirect 80 - to={`/browse/${match?.params.query}`} 81 - push={false} 82 - /> 83 - ); 84 - return <Redirect to="/browse" push={false} />; 85 - }} 86 - </Route> 66 + <Layout> 67 + <Switch> 68 + {/* functional routes */} 69 + <Route exact path="/s/:query"> 70 + <QuickSearch /> 71 + </Route> 72 + <Route exact path="/search/:type"> 73 + <Redirect to="/browse" push={false} /> 74 + </Route> 75 + <Route exact path="/search/:type/:query?"> 76 + {({ match }) => { 77 + if (match?.params.query) 78 + return ( 79 + <Redirect 80 + to={`/browse/${match?.params.query}`} 81 + push={false} 82 + /> 83 + ); 84 + return <Redirect to="/browse" push={false} />; 85 + }} 86 + </Route> 87 87 88 - {/* pages */} 89 - <Route 90 - exact 91 - path={["/media/:media", "/media/:media/:season/:episode"]} 92 - > 93 - <LegacyUrlView> 94 - <PlayerView /> 95 - </LegacyUrlView> 96 - </Route> 97 - <Route 98 - exact 99 - path={["/browse/:query?", "/"]} 100 - component={HomePage} 101 - /> 102 - <Route exact path="/faq" component={AboutPage} /> 103 - <Route exact path="/dmca" component={DmcaPage} /> 88 + {/* pages */} 89 + <Route 90 + exact 91 + path={["/media/:media", "/media/:media/:season/:episode"]} 92 + > 93 + <LegacyUrlView> 94 + <PlayerView /> 95 + </LegacyUrlView> 96 + </Route> 97 + <Route 98 + exact 99 + path={["/browse/:query?", "/"]} 100 + component={HomePage} 101 + /> 102 + <Route exact path="/faq" component={AboutPage} /> 103 + <Route exact path="/dmca" component={DmcaPage} /> 104 104 105 - {/* other */} 106 - <Route 107 - exact 108 - path="/dev" 109 - component={lazy(() => import("@/pages/DeveloperPage"))} 110 - /> 105 + {/* other */} 106 + <Route 107 + exact 108 + path="/dev" 109 + component={lazy(() => import("@/pages/DeveloperPage"))} 110 + /> 111 + <Route 112 + exact 113 + path="/dev/video" 114 + component={lazy( 115 + () => import("@/pages/developer/VideoTesterView") 116 + )} 117 + /> 118 + {/* developer routes that can abuse workers are disabled in production */} 119 + {process.env.NODE_ENV === "development" ? ( 111 120 <Route 112 121 exact 113 - path="/dev/video" 114 - component={lazy( 115 - () => import("@/pages/developer/VideoTesterView") 116 - )} 122 + path="/dev/test" 123 + component={lazy(() => import("@/pages/developer/TestView"))} 117 124 /> 118 - {/* developer routes that can abuse workers are disabled in production */} 119 - {process.env.NODE_ENV === "development" ? ( 120 - <Route 121 - exact 122 - path="/dev/test" 123 - component={lazy(() => import("@/pages/developer/TestView"))} 124 - /> 125 - ) : null} 126 - <Route path="*" component={NotFoundPage} /> 127 - </Switch> 128 - </Layout> 129 - </BannerContextProvider> 125 + ) : null} 126 + <Route path="*" component={NotFoundPage} /> 127 + </Switch> 128 + </Layout> 130 129 </BookmarkContextProvider> 131 130 </WatchedContextProvider> 132 131 </SettingsProvider>
+5 -8
src/setup/Layout.tsx
··· 1 1 import { ReactNode } from "react"; 2 - import { useTranslation } from "react-i18next"; 3 2 4 - import { Banner } from "@/components/Banner"; 5 - import { useBannerSize } from "@/hooks/useBanner"; 6 - import { useIsOnline } from "@/hooks/usePing"; 3 + import { useBannerSize, useBannerStore } from "@/stores/banner"; 4 + import { BannerLocation } from "@/stores/banner/BannerLocation"; 7 5 8 6 export function Layout(props: { children: ReactNode }) { 9 - const { t } = useTranslation(); 10 - const isOnline = useIsOnline(); 11 7 const bannerSize = useBannerSize(); 8 + const location = useBannerStore((s) => s.location); 12 9 13 10 return ( 14 11 <div> 15 12 <div className="fixed inset-x-0 z-[1000]"> 16 - {!isOnline ? <Banner type="error">{t("errors.offline")}</Banner> : null} 13 + <BannerLocation /> 17 14 </div> 18 15 <div 19 16 style={{ 20 - paddingTop: `${bannerSize}px`, 17 + paddingTop: location === null ? `${bannerSize}px` : "0px", 21 18 }} 22 19 className="flex min-h-screen flex-col" 23 20 >
-17
src/setup/sentry.tsx
··· 1 - import { CaptureConsole, HttpClient } from "@sentry/integrations"; 2 - import * as Sentry from "@sentry/react"; 3 - 4 - import { conf } from "@/setup/config"; 5 - import { SENTRY_DSN } from "@/setup/constants"; 6 - 7 - if (process.env.NODE_ENV !== "development") 8 - Sentry.init({ 9 - dsn: SENTRY_DSN, 10 - release: `movie-web@${conf().APP_VERSION}`, 11 - sampleRate: 0.5, 12 - integrations: [ 13 - new Sentry.BrowserTracing(), 14 - new CaptureConsole(), 15 - new HttpClient(), 16 - ], 17 - });
+63
src/stores/banner/BannerLocation.tsx
··· 1 + import { useEffect } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + 4 + import { Icon, Icons } from "@/components/Icon"; 5 + import { useBannerStore, useRegisterBanner } from "@/stores/banner"; 6 + 7 + export function Banner(props: { 8 + children: React.ReactNode; 9 + type: "error"; 10 + id: string; 11 + }) { 12 + const [ref] = useRegisterBanner<HTMLDivElement>(props.id); 13 + const styles = { 14 + error: "bg-[#C93957] text-white", 15 + }; 16 + const icons = { 17 + error: Icons.CIRCLE_EXCLAMATION, 18 + }; 19 + 20 + return ( 21 + <div ref={ref}> 22 + <div 23 + className={[ 24 + styles[props.type], 25 + "flex items-center justify-center p-1", 26 + ].join(" ")} 27 + > 28 + <div className="flex items-center space-x-3"> 29 + <Icon icon={icons[props.type]} /> 30 + <div>{props.children}</div> 31 + </div> 32 + </div> 33 + </div> 34 + ); 35 + } 36 + 37 + export function BannerLocation(props: { location?: string }) { 38 + const { t } = useTranslation(); 39 + const isOnline = useBannerStore((s) => s.isOnline); 40 + const setLocation = useBannerStore((s) => s.setLocation); 41 + const currentLocation = useBannerStore((s) => s.location); 42 + const loc = props.location ?? null; 43 + 44 + useEffect(() => { 45 + if (!loc) return; 46 + setLocation(loc); 47 + return () => { 48 + setLocation(null); 49 + }; 50 + }, [setLocation, loc]); 51 + 52 + if (currentLocation !== loc) return null; 53 + 54 + return ( 55 + <div> 56 + {!isOnline ? ( 57 + <Banner id="offline" type="error"> 58 + {t("errors.offline")} 59 + </Banner> 60 + ) : null} 61 + </div> 62 + ); 63 + }
+88
src/stores/banner/index.ts
··· 1 + import { useEffect } from "react"; 2 + import { useMeasure } from "react-use"; 3 + import { create } from "zustand"; 4 + import { immer } from "zustand/middleware/immer"; 5 + 6 + interface BannerInstance { 7 + id: string; 8 + height: number; 9 + } 10 + 11 + interface BannerStore { 12 + banners: BannerInstance[]; 13 + isOnline: boolean; 14 + location: string | null; 15 + updateHeight(id: string, height: number): void; 16 + showBanner(id: string): void; 17 + hideBanner(id: string): void; 18 + setLocation(loc: string | null): void; 19 + updateOnline(isOnline: boolean): void; 20 + } 21 + 22 + export const useBannerStore = create( 23 + immer<BannerStore>((set) => ({ 24 + banners: [], 25 + isOnline: true, 26 + location: null, 27 + updateOnline(isOnline) { 28 + set((s) => { 29 + s.isOnline = isOnline; 30 + }); 31 + }, 32 + setLocation(loc) { 33 + set((s) => { 34 + s.location = loc; 35 + }); 36 + }, 37 + showBanner(id) { 38 + set((s) => { 39 + if (s.banners.find((v) => v.id === id)) return; 40 + s.banners.push({ 41 + id, 42 + height: 0, 43 + }); 44 + }); 45 + }, 46 + hideBanner(id) { 47 + set((s) => { 48 + s.banners = s.banners.filter((v) => v.id !== id); 49 + }); 50 + }, 51 + updateHeight(id, height) { 52 + set((s) => { 53 + const found = s.banners.find((v) => v.id === id); 54 + if (found) found.height = height; 55 + }); 56 + }, 57 + })) 58 + ); 59 + 60 + export function useBannerSize(location?: string) { 61 + const loc = location ?? null; 62 + const banners = useBannerStore((s) => s.banners); 63 + const currentLocation = useBannerStore((s) => s.location); 64 + 65 + const size = banners.reduce((a, v) => a + v.height, 0); 66 + if (loc !== currentLocation) return 0; 67 + return size; 68 + } 69 + 70 + export function useRegisterBanner<T extends Element>(id: string) { 71 + const [ref, { height }] = useMeasure<T>(); 72 + const updateHeight = useBannerStore((s) => s.updateHeight); 73 + const showBanner = useBannerStore((s) => s.showBanner); 74 + const hideBanner = useBannerStore((s) => s.hideBanner); 75 + 76 + useEffect(() => { 77 + showBanner(id); 78 + return () => { 79 + hideBanner(id); 80 + }; 81 + }, [showBanner, hideBanner, id]); 82 + 83 + useEffect(() => { 84 + updateHeight(id, height); 85 + }, [height, id, updateHeight]); 86 + 87 + return [ref]; 88 + }