diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 61decb79..9329ec0e 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -165,6 +165,12 @@ "close": "Close" }, "player": { + "turnstile": { + "verifyingHumanity": "Verifying your humanity...", + "title": "We need to verify that you're human.", + "description": "Please verify that you are human by completing the Captcha on the right. This is to keep movie-web safe!", + "error": "Failed to verify your humanity. Please try again." + }, "back": { "default": "Back to home", "short": "Back" @@ -261,6 +267,10 @@ "text": "Could not load the media's metadata from TMDB. Please check whether TMDB is down or blocked on your internet connection.", "title": "Failed to load metadata" }, + "api": { + "text": "Could not load API metadata, please check your internet connection.", + "title": "Failed to load API metadata" + }, "notFound": { "badge": "Not found", "homeButton": "Back to home", diff --git a/src/components/overlays/OverlayDisplay.tsx b/src/components/overlays/OverlayDisplay.tsx index 1898a92f..048ec0e7 100644 --- a/src/components/overlays/OverlayDisplay.tsx +++ b/src/components/overlays/OverlayDisplay.tsx @@ -2,12 +2,14 @@ import classNames from "classnames"; import FocusTrap from "focus-trap-react"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; import { Transition } from "@/components/utils/Transition"; import { useInternalOverlayRouter, useRouterAnchorUpdate, } from "@/hooks/useOverlayRouter"; +import { TurnstileProvider } from "@/stores/turnstile"; export interface OverlayProps { id: string; @@ -15,6 +17,34 @@ export interface OverlayProps { darken?: boolean; } +function TurnstileInteractive() { + const { t } = useTranslation(); + const [show, setShow] = useState(false); + + // this may not rerender with different dom structure, must be exactly the same always + return ( +
+
+
+

+ {t("player.turnstile.title")} +

+

{t("player.turnstile.description")}

+
+ setShow(shouldShow)} + /> +
+
+ ); +} + export function OverlayDisplay(props: { children: ReactNode }) { const router = useInternalOverlayRouter("hello world :)"); const refRouter = useRef(router); @@ -27,7 +57,12 @@ export function OverlayDisplay(props: { children: ReactNode }) { r.close(); }; }, []); - return
{props.children}
; + return ( +
+ + {props.children} +
+ ); } export function OverlayPortal(props: { diff --git a/src/pages/parts/player/MetaPart.tsx b/src/pages/parts/player/MetaPart.tsx index 1ea6cd7e..4930fffb 100644 --- a/src/pages/parts/player/MetaPart.tsx +++ b/src/pages/parts/player/MetaPart.tsx @@ -43,7 +43,11 @@ export function MetaPart(props: MetaPartProps) { const { error, value, loading } = useAsync(async () => { const providerApiUrl = getLoadbalancedProviderApiUrl(); if (providerApiUrl) { - await fetchMetadata(providerApiUrl); + try { + await fetchMetadata(providerApiUrl); + } catch (err) { + throw new Error("failed-api-metadata"); + } } else { setCachedMetadata([ ...providers.listSources(), @@ -117,6 +121,28 @@ export function MetaPart(props: MetaPartProps) { ); } + if (error && error.message === "failed-api-metadata") { + return ( + + + + {t("player.metadata.failed.badge")} + + {t("player.metadata.api.text")} + {t("player.metadata.api.title")} + + + + ); + } + if (error) { return ( diff --git a/src/pages/parts/player/ScrapingPart.tsx b/src/pages/parts/player/ScrapingPart.tsx index 6687d3b1..eb0e5bfc 100644 --- a/src/pages/parts/player/ScrapingPart.tsx +++ b/src/pages/parts/player/ScrapingPart.tsx @@ -1,6 +1,7 @@ import { ProviderControls, ScrapeMedia } from "@movie-web/providers"; import classNames from "classnames"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useMountedState } from "react-use"; import type { AsyncReturnType } from "type-fest"; @@ -8,6 +9,8 @@ import { scrapePartsToProviderMetric, useReportProviders, } from "@/backend/helpers/report"; +import { Icon, Icons } from "@/components/Icon"; +import { Loading } from "@/components/layout/Loading"; import { ScrapeCard, ScrapeItem, @@ -18,6 +21,7 @@ import { useListCenter, useScrape, } from "@/hooks/useProviderScrape"; +import { LargeTextPart } from "@/pages/parts/util/LargeTextPart"; export interface ScrapingProps { media: ScrapeMedia; @@ -32,9 +36,11 @@ export function ScrapingPart(props: ScrapingProps) { const { report } = useReportProviders(); const { startScraping, sourceOrder, sources, currentSource } = useScrape(); const isMounted = useMountedState(); + const { t } = useTranslation(); const containerRef = useRef(null); const listRef = useRef(null); + const [failedStartScrape, setFailedStartScrape] = useState(false); const renderedOnce = useListCenter( containerRef, listRef, @@ -72,7 +78,7 @@ export function ScrapingPart(props: ScrapingProps) { ), ); props.onGetStream?.(output); - })(); + })().catch(() => setFailedStartScrape(true)); }, [startScraping, props, report, isMounted]); let currentProviderIndex = sourceOrder.findIndex( @@ -81,11 +87,28 @@ export function ScrapingPart(props: ScrapingProps) { if (currentProviderIndex === -1) currentProviderIndex = sourceOrder.length - 1; + if (failedStartScrape) + return ( + + } + > + {t("player.turnstile.error")} + + ); + return (
+ {!sourceOrder || sourceOrder.length === 0 ? ( +
+ +

{t("player.turnstile.verifyingHumanity")}

+
+ ) : null}
{ const source = sources[order.id]; const distance = Math.abs( - sourceOrder.findIndex((t) => t.id === order.id) - + sourceOrder.findIndex((o) => o.id === order.id) - currentProviderIndex, ); return ( diff --git a/src/stores/banner/index.ts b/src/stores/banner/index.ts index f8173ec2..22df9fc2 100644 --- a/src/stores/banner/index.ts +++ b/src/stores/banner/index.ts @@ -11,24 +11,32 @@ interface BannerInstance { interface BannerStore { banners: BannerInstance[]; isOnline: boolean; + isTurnstile: 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; + updateTurnstile(isTurnstile: boolean): void; } export const useBannerStore = create( immer((set) => ({ banners: [], isOnline: true, + isTurnstile: false, location: null, updateOnline(isOnline) { set((s) => { s.isOnline = isOnline; }); }, + updateTurnstile(isTurnstile) { + set((s) => { + s.isTurnstile = isTurnstile; + }); + }, setLocation(loc) { set((s) => { s.location = loc; diff --git a/src/stores/turnstile/index.tsx b/src/stores/turnstile/index.tsx index b421b70b..72586c73 100644 --- a/src/stores/turnstile/index.tsx +++ b/src/stores/turnstile/index.tsx @@ -1,3 +1,5 @@ +import classNames from "classnames"; +import { useRef } from "react"; import Turnstile, { BoundTurnstileObject } from "react-turnstile"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; @@ -6,19 +8,31 @@ import { reportCaptchaSolve } from "@/backend/helpers/report"; import { conf } from "@/setup/config"; export interface TurnstileStore { - turnstile: BoundTurnstileObject | null; + isInWidget: boolean; + turnstiles: { + controls: BoundTurnstileObject; + isInPopout: boolean; + id: string; + }[]; cbs: ((token: string | null) => void)[]; - setTurnstile(v: BoundTurnstileObject | null): void; + setTurnstile( + id: string, + v: BoundTurnstileObject | null, + isInPopout: boolean, + ): void; getToken(): Promise; - processToken(token: string | null): void; + processToken(token: string | null, widgetId: string): void; } export const useTurnstileStore = create( immer((set, get) => ({ - turnstile: null, + isInWidget: false, + turnstiles: [], cbs: [], - processToken(token) { + processToken(token, widgetId) { const cbs = get().cbs; + const turnstile = get().turnstiles.find((v) => v.id === widgetId); + if (turnstile?.id !== widgetId) return; cbs.forEach((fn) => fn(token)); set((s) => { s.cbs = []; @@ -37,16 +51,26 @@ export const useTurnstileStore = create( }); }); }, - setTurnstile(v) { + setTurnstile(id, controls, isInPopout) { set((s) => { - s.turnstile = v; + s.turnstiles = s.turnstiles.filter((v) => v.id !== id); + if (controls) { + s.turnstiles.push({ + controls, + isInPopout, + id, + }); + } }); }, })), ); export function getTurnstile() { - return useTurnstileStore.getState().turnstile; + const turnstiles = useTurnstileStore.getState().turnstiles; + const inPopout = turnstiles.find((v) => v.isInPopout); + if (inPopout) return inPopout; + return turnstiles[0]; } export function isTurnstileInitialized() { @@ -55,9 +79,12 @@ export function isTurnstileInitialized() { export async function getTurnstileToken() { const turnstile = getTurnstile(); - turnstile?.reset(); - turnstile?.execute(); try { + // I hate turnstile + (window as any).turnstile.execute( + document.querySelector(`#${turnstile.id}`), + {}, + ); const token = await useTurnstileStore.getState().getToken(); reportCaptchaSolve(true); return token; @@ -67,23 +94,44 @@ export async function getTurnstileToken() { } } -export function TurnstileProvider() { +export function TurnstileProvider(props: { + isInPopout?: boolean; + onUpdateShow?: (show: boolean) => void; +}) { const siteKey = conf().TURNSTILE_KEY; + const idRef = useRef(null); const setTurnstile = useTurnstileStore((s) => s.setTurnstile); const processToken = useTurnstileStore((s) => s.processToken); if (!siteKey) return null; return ( - { - setTurnstile(bound); - }} - onError={() => { - processToken(null); - }} - onVerify={(token) => { - processToken(token); - }} - /> +
+ { + idRef.current = widgetId; + setTurnstile(widgetId, bound, !!props.isInPopout); + }} + onError={() => { + const id = idRef.current; + if (!id) return; + processToken(null, id); + }} + onVerify={(token) => { + const id = idRef.current; + if (!id) return; + processToken(token, id); + props.onUpdateShow?.(false); + }} + onBeforeInteractive={() => { + props.onUpdateShow?.(true); + }} + refreshExpired="never" + execution="render" + /> +
); }