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")}
+
+ {t("player.metadata.failed.homeButton")}
+
+
+
+ );
+ }
+
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"
+ />
+
);
}