{props.label ? (
{props.label}
) : null}
diff --git a/src/components/utils/ErrorLine.tsx b/src/components/utils/ErrorLine.tsx
new file mode 100644
index 00000000..b0761fee
--- /dev/null
+++ b/src/components/utils/ErrorLine.tsx
@@ -0,0 +1,18 @@
+import classNames from "classnames";
+import { ReactNode } from "react";
+
+import { Icon, Icons } from "@/components/Icon";
+
+export function ErrorLine(props: { children?: ReactNode; className?: string }) {
+ return (
+
+
+ {props.children}
+
+ );
+}
diff --git a/src/components/utils/Flare.tsx b/src/components/utils/Flare.tsx
index 1fc20825..9c19e027 100644
--- a/src/components/utils/Flare.tsx
+++ b/src/components/utils/Flare.tsx
@@ -69,7 +69,7 @@ function Light(props: FlareProps) {
},
)}
style={{
- backgroundImage: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`,
+ backgroundImage: `radial-gradient(circle at center, rgba(var(${cssVar}) / 1), rgba(var(${cssVar}) / 0) 70%)`,
backgroundPosition: `var(--bg-x) var(--bg-y)`,
backgroundRepeat: "no-repeat",
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
@@ -85,7 +85,7 @@ function Light(props: FlareProps) {
{
+ return needsOnboarding();
+ });
+
+ if (error) throw new Error("Failed to detect onboarding");
+ if (loading) return null;
+ if (value)
+ return (
+
+ );
+ return ;
+}
+
export default PlayerView;
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 0fb27d85..18b755ca 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -31,11 +31,12 @@ import { ThemePart } from "@/pages/parts/settings/ThemePart";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { AccountWithToken, useAuthStore } from "@/stores/auth";
import { useLanguageStore } from "@/stores/language";
+import { usePreferencesStore } from "@/stores/preferences";
import { useSubtitleStore } from "@/stores/subtitles";
import { useThemeStore } from "@/stores/theme";
import { SubPageLayout } from "./layouts/SubPageLayout";
-import { LocalePart } from "./parts/settings/LocalePart";
+import { PreferencesPart } from "./parts/settings/PreferencesPart";
function SettingsLayout(props: { children: React.ReactNode }) {
const { isMobile } = useIsMobile();
@@ -115,6 +116,9 @@ export function SettingsPage() {
const backendUrlSetting = useAuthStore((s) => s.backendUrl);
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
+ const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails);
+ const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails);
+
const account = useAuthStore((s) => s.account);
const updateProfile = useAuthStore((s) => s.setAccountProfile);
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
@@ -136,6 +140,7 @@ export function SettingsPage() {
proxySet,
backendUrlSetting,
account?.profile,
+ enableThumbnails,
);
const saveChanges = useCallback(async () => {
@@ -168,6 +173,7 @@ export function SettingsPage() {
}
}
+ setEnableThumbnails(state.enableThumbnails.state);
setAppLanguage(state.appLanguage.state);
setTheme(state.theme.state);
setSubStyling(state.subtitleStyling.state);
@@ -186,6 +192,7 @@ export function SettingsPage() {
state,
account,
backendUrl,
+ setEnableThumbnails,
setAppLanguage,
setTheme,
setSubStyling,
@@ -225,10 +232,12 @@ export function SettingsPage() {
)}
-
diff --git a/src/pages/layouts/MinimalPageLayout.tsx b/src/pages/layouts/MinimalPageLayout.tsx
new file mode 100644
index 00000000..6d2cf34d
--- /dev/null
+++ b/src/pages/layouts/MinimalPageLayout.tsx
@@ -0,0 +1,28 @@
+import { Link } from "react-router-dom";
+
+import { BrandPill } from "@/components/layout/BrandPill";
+import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
+
+export function MinimalPageLayout(props: { children: React.ReactNode }) {
+ return (
+
+
+ {/* Main page */}
+
+
+
+
+
+
{props.children}
+
+ );
+}
diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx
new file mode 100644
index 00000000..525ed7df
--- /dev/null
+++ b/src/pages/onboarding/Onboarding.tsx
@@ -0,0 +1,102 @@
+import classNames from "classnames";
+import { Trans, useTranslation } from "react-i18next";
+
+import { Button } from "@/components/buttons/Button";
+import { Stepper } from "@/components/layout/Stepper";
+import { CenterContainer } from "@/components/layout/ThinContainer";
+import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
+import { Heading1, Heading2, Paragraph } from "@/components/utils/Text";
+import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
+import {
+ useNavigateOnboarding,
+ useRedirectBack,
+} from "@/pages/onboarding/onboardingHooks";
+import { Card, CardContent, Link } from "@/pages/onboarding/utils";
+import { PageTitle } from "@/pages/parts/util/PageTitle";
+
+function VerticalLine(props: { className?: string }) {
+ return (
+
+ );
+}
+
+export function OnboardingPage() {
+ const navigate = useNavigateOnboarding();
+ const skipModal = useModal("skip");
+ const { completeAndRedirect } = useRedirectBack();
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {t("onboarding.defaultConfirm.title")}
+
+
+ {t("onboarding.defaultConfirm.description")}
+
+
+
+
+
+
+
+
+
+
+ {t("onboarding.start.title")}
+
+
+ {t("onboarding.start.explainer")}
+
+
+
+
navigate("/onboarding/proxy")}>
+
+ {t("onboarding.start.options.proxy.action")}
+
+
+
+
+ or
+
+
+
navigate("/onboarding/extension")}>
+
+ {t("onboarding.start.options.extension.action")}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/onboarding/OnboardingExtension.tsx b/src/pages/onboarding/OnboardingExtension.tsx
new file mode 100644
index 00000000..70472a1a
--- /dev/null
+++ b/src/pages/onboarding/OnboardingExtension.tsx
@@ -0,0 +1,152 @@
+import { ReactNode } from "react";
+import { Trans, useTranslation } from "react-i18next";
+import { useAsyncFn, useInterval } from "react-use";
+
+import { isAllowedExtensionVersion } from "@/backend/extension/compatibility";
+import { extensionInfo, sendPage } from "@/backend/extension/messaging";
+import { Button } from "@/components/buttons/Button";
+import { Icon, Icons } from "@/components/Icon";
+import { Loading } from "@/components/layout/Loading";
+import { Stepper } from "@/components/layout/Stepper";
+import { CenterContainer } from "@/components/layout/ThinContainer";
+import { Heading2, Paragraph } from "@/components/utils/Text";
+import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
+import {
+ useNavigateOnboarding,
+ useRedirectBack,
+} from "@/pages/onboarding/onboardingHooks";
+import { Card, Link } from "@/pages/onboarding/utils";
+import { PageTitle } from "@/pages/parts/util/PageTitle";
+
+type ExtensionStatus =
+ | "unknown"
+ | "failed"
+ | "disallowed"
+ | "noperms"
+ | "outdated"
+ | "success";
+
+async function getExtensionState(): Promise
{
+ const info = await extensionInfo();
+ if (!info) return "unknown"; // cant talk to extension
+ if (!info.success) return "failed"; // extension failed to respond
+ if (!info.allowed) return "disallowed"; // extension is not enabled on this page
+ if (!info.hasPermission) return "noperms"; // extension has no perms to do it's tasks
+ if (!isAllowedExtensionVersion(info.version)) return "outdated"; // extension is too old
+ return "success"; // no problems
+}
+
+export function ExtensionStatus(props: {
+ status: ExtensionStatus;
+ loading: boolean;
+}) {
+ const { t } = useTranslation();
+
+ let content: ReactNode = null;
+ if (props.loading || props.status === "unknown")
+ content = (
+ <>
+
+ {t("onboarding.extension.status.loading")}
+ >
+ );
+ if (props.status === "disallowed" || props.status === "noperms")
+ content = (
+ <>
+ {t("onboarding.extension.status.disallowed")}
+
+ >
+ );
+ else if (props.status === "failed")
+ content = {t("onboarding.extension.status.failed")}
;
+ else if (props.status === "outdated")
+ content = {t("onboarding.extension.status.outdated")}
;
+ else if (props.status === "success")
+ content = (
+
+
+ {t("onboarding.extension.status.success")}
+
+ );
+ return (
+ <>
+
+
+ {content}
+
+
+
+
+
+ >
+ );
+}
+
+export function OnboardingExtensionPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigateOnboarding();
+ const { completeAndRedirect } = useRedirectBack();
+
+ const [{ loading, value }, exec] = useAsyncFn(
+ async (triggeredManually: boolean = false) => {
+ const status = await getExtensionState();
+ if (status === "success" && triggeredManually) completeAndRedirect();
+ return status;
+ },
+ [completeAndRedirect],
+ );
+ useInterval(exec, 1000);
+
+ // TODO proper link to install extension
+ return (
+
+
+
+
+
+ {t("onboarding.extension.title")}
+
+
+ {t("onboarding.extension.explainer")}
+
+
+ {t("onboarding.extension.link")}
+
+
+
+
+
+ {value === "success" ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/pages/onboarding/OnboardingProxy.tsx b/src/pages/onboarding/OnboardingProxy.tsx
new file mode 100644
index 00000000..e576779c
--- /dev/null
+++ b/src/pages/onboarding/OnboardingProxy.tsx
@@ -0,0 +1,80 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useAsyncFn } from "react-use";
+
+import { singularProxiedFetch } from "@/backend/helpers/fetch";
+import { Button } from "@/components/buttons/Button";
+import { Stepper } from "@/components/layout/Stepper";
+import { CenterContainer } from "@/components/layout/ThinContainer";
+import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
+import { Divider } from "@/components/utils/Divider";
+import { ErrorLine } from "@/components/utils/ErrorLine";
+import { Heading2, Paragraph } from "@/components/utils/Text";
+import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
+import {
+ useNavigateOnboarding,
+ useRedirectBack,
+} from "@/pages/onboarding/onboardingHooks";
+import { Link } from "@/pages/onboarding/utils";
+import { PageTitle } from "@/pages/parts/util/PageTitle";
+import { useAuthStore } from "@/stores/auth";
+
+const testUrl = "https://postman-echo.com/get";
+
+export function OnboardingProxyPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigateOnboarding();
+ const { completeAndRedirect } = useRedirectBack();
+ const [url, setUrl] = useState("");
+ const setProxySet = useAuthStore((s) => s.setProxySet);
+
+ const [{ loading, error }, test] = useAsyncFn(async () => {
+ if (!url.startsWith("http"))
+ throw new Error("onboarding.proxy.input.errorInvalidUrl");
+ try {
+ const res = await singularProxiedFetch(url, testUrl, {});
+ if (res.url !== testUrl)
+ throw new Error("onboarding.proxy.input.errorNotProxy");
+ setProxySet([url]);
+ completeAndRedirect();
+ } catch (e) {
+ throw new Error("onboarding.proxy.input.errorConnection");
+ }
+ }, [url, completeAndRedirect, setProxySet]);
+
+ // TODO proper link to proxy deployment docs
+ return (
+
+
+
+
+
+ {t("onboarding.proxy.title")}
+
+
+ {t("onboarding.proxy.explainer")}
+
+ {t("onboarding.proxy.link")}
+
+
+ {error ?
{t(error.message)} : null}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/onboarding/onboardingHooks.ts b/src/pages/onboarding/onboardingHooks.ts
new file mode 100644
index 00000000..cccf8825
--- /dev/null
+++ b/src/pages/onboarding/onboardingHooks.ts
@@ -0,0 +1,37 @@
+import { useCallback } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+
+import { useQueryParam } from "@/hooks/useQueryParams";
+import { useOnboardingStore } from "@/stores/onboarding";
+
+export function useRedirectBack() {
+ const [url] = useQueryParam("redirect");
+ const navigate = useNavigate();
+ const setCompleted = useOnboardingStore((s) => s.setCompleted);
+
+ const redirectBack = useCallback(() => {
+ navigate(url ?? "/");
+ }, [navigate, url]);
+
+ const completeAndRedirect = useCallback(() => {
+ setCompleted(true);
+ redirectBack();
+ }, [redirectBack, setCompleted]);
+
+ return { completeAndRedirect };
+}
+
+export function useNavigateOnboarding() {
+ const navigate = useNavigate();
+ const loc = useLocation();
+ const nav = useCallback(
+ (path: string) => {
+ navigate({
+ pathname: path,
+ search: loc.search,
+ });
+ },
+ [navigate, loc],
+ );
+ return nav;
+}
diff --git a/src/pages/onboarding/utils.tsx b/src/pages/onboarding/utils.tsx
new file mode 100644
index 00000000..3a57952b
--- /dev/null
+++ b/src/pages/onboarding/utils.tsx
@@ -0,0 +1,91 @@
+import classNames from "classnames";
+import { ReactNode } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { Icon, Icons } from "@/components/Icon";
+import { Heading2, Heading3, Paragraph } from "@/components/utils/Text";
+
+export function Card(props: {
+ children?: React.ReactNode;
+ className?: string;
+ onClick?: () => void;
+}) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function CardContent(props: {
+ title: ReactNode;
+ description: ReactNode;
+ subtitle: ReactNode;
+ colorClass: string;
+ children?: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {props.subtitle}
+
+
{props.title}
+
+ {props.description}
+
+
+
{props.children}
+
+ );
+}
+
+export function Link(props: {
+ children?: React.ReactNode;
+ to?: string;
+ href?: string;
+ className?: string;
+ target?: "_blank";
+}) {
+ const navigate = useNavigate();
+ return (
+ {
+ if (props.to) navigate(props.to);
+ }}
+ href={props.href}
+ target={props.target}
+ className={classNames(
+ "text-onboarding-link cursor-pointer inline-flex gap-2 items-center group hover:opacity-75 transition-opacity",
+ props.className,
+ )}
+ rel="noreferrer"
+ >
+ {props.children}
+
+
+ );
+}
diff --git a/src/pages/parts/player/MetaPart.tsx b/src/pages/parts/player/MetaPart.tsx
index 4930fffb..1b84b7bf 100644
--- a/src/pages/parts/player/MetaPart.tsx
+++ b/src/pages/parts/player/MetaPart.tsx
@@ -3,6 +3,8 @@ import { useNavigate, useParams } from "react-router-dom";
import { useAsync } from "react-use";
import type { AsyncReturnType } from "type-fest";
+import { isAllowedExtensionVersion } from "@/backend/extension/compatibility";
+import { extensionInfo, sendPage } from "@/backend/extension/messaging";
import {
fetchMetadata,
setCachedMetadata,
@@ -10,6 +12,8 @@ import {
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
import { decodeTMDBId } from "@/backend/metadata/tmdb";
import { MWMediaType } from "@/backend/metadata/types/mw";
+import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers";
+import { getProviders } from "@/backend/providers/providers";
import { Button } from "@/components/buttons/Button";
import { Icons } from "@/components/Icon";
import { IconPill } from "@/components/layout/IconPill";
@@ -18,7 +22,6 @@ import { Paragraph } from "@/components/text/Paragraph";
import { Title } from "@/components/text/Title";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
import { conf } from "@/setup/config";
-import { getLoadbalancedProviderApiUrl, providers } from "@/utils/providers";
export interface MetaPartProps {
onGetMeta?: (meta: DetailedMeta, episodeId?: string) => void;
@@ -41,8 +44,17 @@ export function MetaPart(props: MetaPartProps) {
const navigate = useNavigate();
const { error, value, loading } = useAsync(async () => {
+ const info = await extensionInfo();
+ const isValidExtension =
+ info?.success && isAllowedExtensionVersion(info.version) && info.allowed;
+
+ if (isValidExtension) {
+ if (!info.hasPermission) throw new Error("extension-no-permission");
+ }
+
+ // use api metadata or providers metadata
const providerApiUrl = getLoadbalancedProviderApiUrl();
- if (providerApiUrl) {
+ if (providerApiUrl && !isValidExtension) {
try {
await fetchMetadata(providerApiUrl);
} catch (err) {
@@ -50,11 +62,12 @@ export function MetaPart(props: MetaPartProps) {
}
} else {
setCachedMetadata([
- ...providers.listSources(),
- ...providers.listEmbeds(),
+ ...getProviders().listSources(),
+ ...getProviders().listEmbeds(),
]);
}
+ // get media meta data
let data: ReturnType = null;
try {
if (!params.media) throw new Error("no media params");
@@ -98,16 +111,42 @@ export function MetaPart(props: MetaPartProps) {
props.onGetMeta?.(meta, epId);
}, []);
+ if (error && error.message === "extension-no-permission") {
+ return (
+
+
+
+ {t("player.metadata.extensionPermission.badge")}
+
+ {t("player.metadata.extensionPermission.title")}
+ {t("player.metadata.extensionPermission.text")}
+
+
+
+ );
+ }
+
if (error && error.message === "dmca") {
return (
- Removed
- Media has been removed
-
- This media is no longer available due to a takedown notice or
- copyright claim.
-
+
+ {t("player.metadata.dmca.badge")}
+
+ {t("player.metadata.dmca.title")}
+ {t("player.metadata.dmca.text")}