diff --git a/package.json b/package.json index 0f61bb0b..c5dae16f 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,9 @@ "@formkit/auto-animate": "^0.8.1", "@headlessui/react": "^1.7.17", "@ladjs/country-language": "^1.0.3", - "@movie-web/providers": "^2.0.5", + "@movie-web/providers": "^2.1.0", "@noble/hashes": "^1.3.3", + "@plasmohq/messaging": "^0.6.1", "@react-spring/web": "^9.7.3", "@scure/bip39": "^1.2.2", "@sozialhelden/ietf-language-tags": "^5.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a7203ea..2fda1a32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,11 +22,14 @@ dependencies: specifier: ^1.0.3 version: 1.0.3 '@movie-web/providers': - specifier: ^2.0.5 - version: 2.0.5 + specifier: ^2.1.0 + version: 2.1.0 '@noble/hashes': specifier: ^1.3.3 version: 1.3.3 + '@plasmohq/messaging': + specifier: ^0.6.1 + version: 0.6.1(react@18.2.0) '@react-spring/web': specifier: ^9.7.3 version: 9.7.3(react-dom@18.2.0)(react@18.2.0) @@ -1921,8 +1924,8 @@ packages: engines: {node: '>= 14'} dev: false - /@movie-web/providers@2.0.5: - resolution: {integrity: sha512-cefPTFXE7ctYeiibjk4HcNL3anRZ3lgYDAaJdzFzUrvkcSdxonP8GgGfDfPwmWWKip9dbP8Xv5aeauV/wrfaag==} + /@movie-web/providers@2.1.0: + resolution: {integrity: sha512-L7Nn5n1+0HNXha0A6bymJSGVLhyC4qd5S2r5Xk5FeqxMlqKBqOlMpUmfHiZOssog70sxTAvRfFqmKkM4UXV8kg==} dependencies: cheerio: 1.0.0-rc.12 crypto-js: 4.2.0 @@ -1980,6 +1983,18 @@ packages: tslib: 2.6.2 dev: true + /@plasmohq/messaging@0.6.1(react@18.2.0): + resolution: {integrity: sha512-/nn1k8SG5z++o/NnZu+byHWcC9MhPLxfmvj+AP3buqMn7uwfYDcYWURLuMW2Knw08HBg+wku2v1Ltt4evN0nzA==} + peerDependencies: + react: ^16.8.6 || ^17 || ^18 + peerDependenciesMeta: + react: + optional: true + dependencies: + nanoid: 5.0.3 + react: 18.2.0 + dev: false + /@react-spring/animated@9.7.3(react@18.2.0): resolution: {integrity: sha512-5CWeNJt9pNgyvuSzQH+uy2pvTg8Y4/OisoscZIR8/ZNLIOI+CatFBhGZpDGTF/OzdNFsAoGk3wiUYTwoJ0YIvw==} peerDependencies: @@ -5166,6 +5181,12 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + /nanoid@5.0.3: + resolution: {integrity: sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==} + engines: {node: ^18 || >=20} + hasBin: true + dev: false + /nanoid@5.0.4: resolution: {integrity: sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==} engines: {node: ^18 || >=20} diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 425f6c32..15038d6f 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -97,7 +97,8 @@ "login": "Login", "pagetitle": "{{title}} - movie-web", "register": "Register", - "settings": "Settings" + "settings": "Settings", + "onboarding": "Setup" } }, "home": { @@ -231,7 +232,7 @@ "downloadSubtitle": "Download current subtitle", "downloadPlaylist": "Download playlist", "downloadVideo": "Download video", - "hlsDisclaimer": "Downloads are taken directly from the provider. movie-web does not have control over how the downloads are provided. Please note that you are downloading an HLS playlist, this is intended for users familiar with advanced multimedia streaming.", + "hlsDisclaimer": "Downloads are taken directly from the provider. movie-web does not have control over how the downloads are provided.

Please note that you are downloading an HLS playlist, it is not recommended to download if you are not familiar with advanced streaming formats. Try different sources for different formats.", "onAndroid": { "1": "To download on Android, click the download button then, on the new page, tap and hold on the video, then select save.", "shortTitle": "Download / Android", @@ -276,6 +277,17 @@ "homeButton": "Back to home", "text": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL.", "title": "Couldn't find that media." + }, + "extensionPermission": { + "badge": "Permission Missing", + "title": "Configure the extension", + "text": "You have the browser extension, but we need your permission to get started using the extension.", + "button": "Use extension" + }, + "dmca": { + "badge": "Removed", + "title": "Media has been removed", + "text": "This media is no longer available due to a takedown notice or copyright claim." } }, "nextEpisode": { @@ -392,6 +404,28 @@ "colorLabel": "Color" }, "connections": { + "setup": { + "errorStatus": { + "title": "Something needs your attention", + "description": "It seems that one or more items in this setup need your attention." + }, + "unsetStatus": { + "title": "You haven't gone through setup", + "description": "Please click the button to the right to start the setup process." + }, + "successStatus": { + "title": "Everything is set up!", + "description": "All things are in place for you to start watching your favourite media." + }, + "redoSetup": "Redo setup", + "doSetup": "Do setup", + "itemError": "There is something wrong with this setting. Go through setup again to fix it.", + "items": { + "extension": "Extension", + "proxy": "Custom proxy", + "default": "Default setup" + } + }, "server": { "description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.", "label": "Custom server", @@ -407,10 +441,13 @@ "urlPlaceholder": "https://" } }, - "locale": { + "preferences": { "language": "Application language", "languageDescription": "Language applied to the entire application.", - "title": "Locale" + "title": "Preferences", + "thumbnail": "Generate thumbnails", + "thumbnailDescription": "Most of the time, videos don't have thumbnails. You can enable this setting to generate them on the fly but they can make your video slower.", + "thumbnailLabel": "Generate thumbnails" }, "reset": "Reset", "save": "Save", @@ -429,5 +466,64 @@ } }, "unsaved": "You have unsaved changes" + }, + "onboarding": { + "start": { + "title": "Let's get you setup with movie-web", + "explainer": "To get the best streams possible. You will need to choose which streaming method you want to use.", + "options": { + "proxy": { + "quality": "Good quality", + "title": "Custom proxy", + "description": "Setup a proxy in just 5 minutes and gain access to great sources.", + "action": "Setup proxy" + }, + "extension": { + "quality": "Best quality", + "title": "Browser extension", + "description": "Install browser extension and gain access to the best sources.", + "action": "Install extension" + }, + "default": { + "text": "I don't want good quality streams,<0 /> <1>use the default setup" + } + } + }, + "proxy": { + "title": "Let's make a new proxy", + "explainer": "With the proxy method, you can get great quality streams by making a self-service proxy.", + "link": "Learn how to make a proxy", + "input": { + "label": "Proxy URL", + "placeholder": "https://", + "errorInvalidUrl": "Not a valid URL", + "errorConnection": "Could not connect to proxy", + "errorNotProxy": "Expected a proxy but got a website" + }, + "back": "Go back", + "submit": "Submit proxy" + }, + "extension": { + "title": "Let's start with an extension", + "explainer": "Using the browser extension, you can get the best streams we have to offer. With just a simple install.", + "extensionHelp": "If you've installed the extension but it's not detected. Open the extension through your browsers extension menu and follow the steps on screen.", + "link": "Install extension", + "back": "Go back", + "status": { + "loading": "Waiting for you to install the extension", + "disallowed": "Extension is not enabled for this page", + "disallowedAction": "Enable extension", + "failed": "Failed to request status", + "outdated": "Extension version too old", + "success": "Extension is working as expected!" + }, + "submit": "Continue" + }, + "defaultConfirm": { + "title": "Are you sure?", + "description": "The default setup does not have the best streams and can be unbearably slow.", + "cancel": "Cancel", + "confirm": "Use default setup" + } } } diff --git a/src/backend/extension/compatibility.ts b/src/backend/extension/compatibility.ts new file mode 100644 index 00000000..31471d26 --- /dev/null +++ b/src/backend/extension/compatibility.ts @@ -0,0 +1,5 @@ +const allowedExtensionVersion = ["0.0.1"]; + +export function isAllowedExtensionVersion(version: string): boolean { + return allowedExtensionVersion.includes(version); +} diff --git a/src/backend/extension/messaging.ts b/src/backend/extension/messaging.ts new file mode 100644 index 00000000..1f2a2b00 --- /dev/null +++ b/src/backend/extension/messaging.ts @@ -0,0 +1,71 @@ +import { + MessagesMetadata, + sendToBackgroundViaRelay, +} from "@plasmohq/messaging"; + +import { isAllowedExtensionVersion } from "@/backend/extension/compatibility"; +import { ExtensionMakeRequestResponse } from "@/backend/extension/plasmo"; + +let activeExtension = false; + +function sendMessage( + message: MessageKey, + payload: MessagesMetadata[MessageKey]["req"] | undefined = undefined, + timeout: number = -1, +) { + return new Promise((resolve) => { + if (timeout >= 0) setTimeout(() => resolve(null), timeout); + sendToBackgroundViaRelay< + MessagesMetadata[MessageKey]["req"], + MessagesMetadata[MessageKey]["res"] + >({ + name: message, + body: payload, + }) + .then((res) => { + activeExtension = true; + resolve(res); + }) + .catch(() => { + activeExtension = false; + resolve(null); + }); + }); +} + +export async function sendExtensionRequest( + ops: MessagesMetadata["makeRequest"]["req"], +): Promise | null> { + return sendMessage("makeRequest", ops); +} + +export async function setDomainRule( + ops: MessagesMetadata["prepareStream"]["req"], +): Promise { + return sendMessage("prepareStream", ops); +} + +export async function sendPage( + ops: MessagesMetadata["openPage"]["req"], +): Promise { + return sendMessage("openPage", ops); +} + +export async function extensionInfo(): Promise< + MessagesMetadata["hello"]["res"] | null +> { + const message = await sendMessage("hello", undefined, 300); + return message; +} + +export function isExtensionActiveCached(): boolean { + return activeExtension; +} + +export async function isExtensionActive(): Promise { + const info = await extensionInfo(); + if (!info?.success) return false; + const allowedVersion = isAllowedExtensionVersion(info.version); + if (!allowedVersion) return false; + return info.allowed && info.hasPermission; +} diff --git a/src/backend/extension/plasmo.ts b/src/backend/extension/plasmo.ts new file mode 100644 index 00000000..c13898be --- /dev/null +++ b/src/backend/extension/plasmo.ts @@ -0,0 +1,68 @@ +export interface ExtensionBaseRequest {} + +export type ExtensionBaseResponse = + | ({ + success: true; + } & T) + | { + success: false; + error: string; + }; + +export type ExtensionHelloResponse = ExtensionBaseResponse<{ + version: string; + allowed: boolean; + hasPermission: boolean; +}>; + +export interface ExtensionMakeRequest extends ExtensionBaseRequest { + url: string; + method: string; + headers?: Record; + body?: string | FormData | URLSearchParams | Record; +} + +export type ExtensionMakeRequestResponse = ExtensionBaseResponse<{ + response: { + statusCode: number; + headers: Record; + finalUrl: string; + body: T; + }; +}>; + +export interface ExtensionPrepareStreamRequest extends ExtensionBaseRequest { + ruleId: number; + targetDomains: string[]; + requestHeaders?: Record; + responseHeaders?: Record; +} + +export interface MmMetadata { + hello: { + req: ExtensionBaseRequest; + res: ExtensionHelloResponse; + }; + makeRequest: { + req: ExtensionMakeRequest; + res: ExtensionMakeRequestResponse; + }; + prepareStream: { + req: ExtensionPrepareStreamRequest; + res: ExtensionBaseResponse; + }; + openPage: { + req: ExtensionBaseRequest & { + page: string; + redirectUrl: string; + }; + res: ExtensionBaseResponse; + }; +} + +interface MpMetadata {} + +declare module "@plasmohq/messaging" { + interface MessagesMetadata extends MmMetadata {} + interface PortsMetadata extends MpMetadata {} +} diff --git a/src/backend/extension/streams.ts b/src/backend/extension/streams.ts new file mode 100644 index 00000000..588718af --- /dev/null +++ b/src/backend/extension/streams.ts @@ -0,0 +1,43 @@ +import { Stream } from "@movie-web/providers"; + +import { setDomainRule } from "@/backend/extension/messaging"; + +function extractDomain(url: string): string | null { + try { + const u = new URL(url); + return u.hostname; + } catch { + return null; + } +} + +function extractDomainsFromStream(stream: Stream): string[] { + if (stream.type === "hls") { + return [extractDomain(stream.playlist)].filter((v): v is string => !!v); + } + if (stream.type === "file") { + return Object.values(stream.qualities) + .map((v) => extractDomain(v.url)) + .filter((v): v is string => !!v); + } + return []; +} + +function buildHeadersFromStream(stream: Stream): Record { + const headers: Record = {}; + Object.entries(stream.headers ?? {}).forEach((entry) => { + headers[entry[0]] = entry[1]; + }); + Object.entries(stream.preferredHeaders ?? {}).forEach((entry) => { + headers[entry[0]] = entry[1]; + }); + return headers; +} + +export async function prepareStream(stream: Stream) { + await setDomainRule({ + ruleId: 1, + targetDomains: extractDomainsFromStream(stream), + requestHeaders: buildHeadersFromStream(stream), + }); +} diff --git a/src/backend/helpers/fetch.ts b/src/backend/helpers/fetch.ts index cc3e735e..f9aa145a 100644 --- a/src/backend/helpers/fetch.ts +++ b/src/backend/helpers/fetch.ts @@ -1,7 +1,7 @@ import { ofetch } from "ofetch"; import { getApiToken, setApiToken } from "@/backend/helpers/providerApi"; -import { getLoadbalancedProxyUrl } from "@/utils/providers"; +import { getLoadbalancedProxyUrl } from "@/backend/providers/fetchers"; type P = Parameters>; type R = ReturnType>; diff --git a/src/utils/providers.ts b/src/backend/providers/fetchers.ts similarity index 58% rename from src/utils/providers.ts rename to src/backend/providers/fetchers.ts index e5c8503c..95267595 100644 --- a/src/utils/providers.ts +++ b/src/backend/providers/fetchers.ts @@ -1,12 +1,6 @@ -import { - Fetcher, - ProviderControls, - makeProviders, - makeSimpleProxyFetcher, - makeStandardFetcher, - targets, -} from "@movie-web/providers"; +import { Fetcher, makeSimpleProxyFetcher } from "@movie-web/providers"; +import { sendExtensionRequest } from "@/backend/extension/messaging"; import { getApiToken, setApiToken } from "@/backend/helpers/providerApi"; import { getProviderApiUrls, getProxyUrls } from "@/utils/proxyUrls"; @@ -48,7 +42,7 @@ async function fetchButWithApiTokens( return response; } -function makeLoadBalancedSimpleProxyFetcher() { +export function makeLoadBalancedSimpleProxyFetcher() { const fetcher: Fetcher = async (a, b) => { const currentFetcher = makeSimpleProxyFetcher( getLoadbalancedProxyUrl(), @@ -59,8 +53,32 @@ function makeLoadBalancedSimpleProxyFetcher() { return fetcher; } -export const providers = makeProviders({ - fetcher: makeStandardFetcher(fetch), - proxiedFetcher: makeLoadBalancedSimpleProxyFetcher(), - target: targets.BROWSER, -}) as any as ProviderControls; +function makeFinalHeaders( + readHeaders: string[], + headers: Record, +): Headers { + const lowercasedHeaders = readHeaders.map((v) => v.toLowerCase()); + return new Headers( + Object.entries(headers).filter((entry) => + lowercasedHeaders.includes(entry[0].toLowerCase()), + ), + ); +} + +export function makeExtensionFetcher() { + const fetcher: Fetcher = async (url, ops) => { + const result = (await sendExtensionRequest({ + url, + ...ops, + })) as any; + if (!result?.success) throw new Error(`extension error: ${result?.error}`); + const res = result.response; + return { + body: res.body, + finalUrl: res.finalUrl, + statusCode: res.statusCode, + headers: makeFinalHeaders(ops.readHeaders, res.headers), + }; + }; + return fetcher; +} diff --git a/src/backend/providers/providers.ts b/src/backend/providers/providers.ts new file mode 100644 index 00000000..ac4a7dfa --- /dev/null +++ b/src/backend/providers/providers.ts @@ -0,0 +1,27 @@ +import { + makeProviders, + makeStandardFetcher, + targets, +} from "@movie-web/providers"; + +import { isExtensionActiveCached } from "@/backend/extension/messaging"; +import { + makeExtensionFetcher, + makeLoadBalancedSimpleProxyFetcher, +} from "@/backend/providers/fetchers"; + +export function getProviders() { + if (isExtensionActiveCached()) { + return makeProviders({ + fetcher: makeExtensionFetcher(), + target: targets.BROWSER_EXTENSION, + consistentIpForRequests: true, + }); + } + + return makeProviders({ + fetcher: makeStandardFetcher(fetch), + proxiedFetcher: makeLoadBalancedSimpleProxyFetcher(), + target: targets.BROWSER, + }); +} diff --git a/src/components/buttons/Toggle.tsx b/src/components/buttons/Toggle.tsx index 3fcb0071..8cedc245 100644 --- a/src/components/buttons/Toggle.tsx +++ b/src/components/buttons/Toggle.tsx @@ -1,6 +1,6 @@ import classNames from "classnames"; -export function Toggle(props: { onClick: () => void; enabled?: boolean }) { +export function Toggle(props: { onClick?: () => void; enabled?: boolean }) { return ( + + + + + + + + {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")} + + + + ); +} diff --git a/src/pages/parts/settings/SidebarPart.tsx b/src/pages/parts/settings/SidebarPart.tsx index 2b6e5c3f..47469a5e 100644 --- a/src/pages/parts/settings/SidebarPart.tsx +++ b/src/pages/parts/settings/SidebarPart.tsx @@ -44,9 +44,9 @@ export function SidebarPart() { icon: Icons.USER, }, { - textKey: "settings.locale.title", - id: "settings-locale", - icon: Icons.BOOKMARK, + textKey: "settings.preferences.title", + id: "settings-preferences", + icon: Icons.SETTINGS, }, { textKey: "settings.appearance.title", diff --git a/src/setup/App.tsx b/src/setup/App.tsx index afa5f8ba..37c55e0f 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -19,6 +19,9 @@ import { DmcaPage, shouldHaveDmcaPage } from "@/pages/Dmca"; import { NotFoundPage } from "@/pages/errors/NotFoundPage"; import { HomePage } from "@/pages/HomePage"; import { LoginPage } from "@/pages/Login"; +import { OnboardingPage } from "@/pages/onboarding/Onboarding"; +import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension"; +import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy"; import { RegisterPage } from "@/pages/Register"; import { Layout } from "@/setup/Layout"; import { useHistoryListener } from "@/stores/history"; @@ -119,6 +122,12 @@ function App() { } /> } /> } /> + } /> + } + /> + } /> {shouldHaveDmcaPage() ? ( } /> diff --git a/src/setup/config.ts b/src/setup/config.ts index 2e1634a4..d6aca1cb 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -19,6 +19,7 @@ interface Config { DISALLOWED_IDS: string; TURNSTILE_KEY: string; CDN_REPLACEMENTS: string; + HAS_ONBOARDING: string; } export interface RuntimeConfig { @@ -34,6 +35,7 @@ export interface RuntimeConfig { DISALLOWED_IDS: string[]; TURNSTILE_KEY: string | null; CDN_REPLACEMENTS: Array; + HAS_ONBOARDING: boolean; } const env: Record = { @@ -49,6 +51,7 @@ const env: Record = { DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS, TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY, CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS, + HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, }; // loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js) @@ -82,6 +85,7 @@ export function conf(): RuntimeConfig { .split(",") .map((v) => v.trim()), NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true", + HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true", TURNSTILE_KEY: turnstileKey.length > 0 ? turnstileKey : null, DISALLOWED_IDS: getKey("DISALLOWED_IDS", "") .split(",") diff --git a/src/stores/history/index.ts b/src/stores/history/index.ts index c90a7309..c1f507ee 100644 --- a/src/stores/history/index.ts +++ b/src/stores/history/index.ts @@ -46,7 +46,8 @@ export function useLastNonPlayerLink() { (v) => !v.path.startsWith("/media") && // cannot be a player link location.pathname !== v.path && // cannot be current link - !v.path.startsWith("/s/"), // cannot be a quick search link + !v.path.startsWith("/s/") && // cannot be a quick search link + !v.path.startsWith("/onboarding"), // cannot be an onboarding link ); return route?.path ?? "/"; }, [routes, location]); diff --git a/src/stores/onboarding/index.tsx b/src/stores/onboarding/index.tsx new file mode 100644 index 00000000..be5af563 --- /dev/null +++ b/src/stores/onboarding/index.tsx @@ -0,0 +1,22 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; + +export interface OnboardingStore { + completed: boolean; + setCompleted(v: boolean): void; +} + +export const useOnboardingStore = create( + persist( + immer((set) => ({ + completed: false, + setCompleted(v) { + set((s) => { + s.completed = v; + }); + }, + })), + { name: "__MW::onboarding" }, + ), +); diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 1e26abc2..0312b8b0 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -118,6 +118,7 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ }, setSourceId(id) { set((s) => { + s.status = playerStatus.PLAYING; s.sourceId = id; }); }, @@ -155,6 +156,8 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ s.qualities = qualities as SourceQuality[]; s.currentQuality = loadableStream.quality; s.captionList = captions; + s.interface.error = undefined; + s.status = playerStatus.PLAYING; }); const store = get(); store.redisplaySource(startAt); @@ -168,7 +171,10 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ automaticQuality: qualityPreferences.quality.automaticQuality, lastChosenQuality: quality, }); - + set((s) => { + s.interface.error = undefined; + s.status = playerStatus.PLAYING; + }); store.display?.load({ source: loadableStream.stream, startAt, @@ -184,6 +190,8 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ if (!selectedQuality) return; set((s) => { s.currentQuality = quality; + s.status = playerStatus.PLAYING; + s.interface.error = undefined; }); store.display?.load({ source: selectedQuality, diff --git a/src/stores/player/utils/qualities.ts b/src/stores/player/utils/qualities.ts index dbd84b5c..e5140d53 100644 --- a/src/stores/player/utils/qualities.ts +++ b/src/stores/player/utils/qualities.ts @@ -1,4 +1,4 @@ -import { Qualities } from "@movie-web/providers"; +import { Qualities, Stream } from "@movie-web/providers"; import { QualityStore } from "@/stores/quality"; @@ -14,16 +14,19 @@ export type SourceFileStream = { export type LoadableSource = { type: StreamType; url: string; + preferredHeaders?: Stream["preferredHeaders"]; }; export type SourceSliceSource = | { type: "file"; qualities: Partial>; + preferredHeaders?: Stream["preferredHeaders"]; } | { type: "hls"; url: string; + preferredHeaders?: Stream["preferredHeaders"]; }; const qualitySorting: Record = { diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx new file mode 100644 index 00000000..65fae22d --- /dev/null +++ b/src/stores/preferences/index.tsx @@ -0,0 +1,24 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; + +export interface PreferencesStore { + enableThumbnails: boolean; + setEnableThumbnails(v: boolean): void; +} + +export const usePreferencesStore = create( + persist( + immer((set) => ({ + enableThumbnails: false, + setEnableThumbnails(v) { + set((s) => { + s.enableThumbnails = v; + }); + }, + })), + { + name: "__MW::preferences", + }, + ), +); diff --git a/src/utils/language.ts b/src/utils/language.ts index 6fda7df8..41b8168b 100644 --- a/src/utils/language.ts +++ b/src/utils/language.ts @@ -86,7 +86,7 @@ function populateLanguageCode(language: string): string { * @returns pretty format for language, null if it no info can be found for language */ export function getPrettyLanguageNameFromLocale(locale: string): string | null { - const tag = getTag(populateLanguageCode(locale), true); + const tag = getTag(locale, true); const lang = tag?.language?.Description?.[0] ?? null; if (!lang) return null; diff --git a/src/utils/onboarding.ts b/src/utils/onboarding.ts new file mode 100644 index 00000000..c2678b1c --- /dev/null +++ b/src/utils/onboarding.ts @@ -0,0 +1,23 @@ +import { isExtensionActive } from "@/backend/extension/messaging"; +import { conf } from "@/setup/config"; +import { useAuthStore } from "@/stores/auth"; +import { useOnboardingStore } from "@/stores/onboarding"; + +export async function needsOnboarding(): Promise { + // if onboarding is dislabed, no onboarding needed + if (!conf().HAS_ONBOARDING) return false; + + // if extension is active and working, no onboarding needed + const extensionActive = await isExtensionActive(); + if (extensionActive) return false; + + // if there is any custom proxy urls, no onboarding needed + const proxyUrls = useAuthStore.getState().proxySet; + if (proxyUrls) return false; + + // if onboarding has been completed, no onboarding needed + const completed = useOnboardingStore.getState().completed; + if (completed) return false; + + return true; +} diff --git a/themes/default.ts b/themes/default.ts index 7a010338..bd31b3ff 100644 --- a/themes/default.ts +++ b/themes/default.ts @@ -137,6 +137,11 @@ export const defaultTheme = { accentA: tokens.purple.c500, accentB: tokens.blue.c500, }, + + // Modals + modal: { + background: tokens.shade.c800, + }, // typography type: { @@ -147,6 +152,7 @@ export const defaultTheme = { divider: tokens.ash.c500, secondary: tokens.ash.c100, danger: tokens.semantic.red.c100, + success: tokens.semantic.green.c100, link: tokens.purple.c100, linkHover: tokens.purple.c50, }, @@ -228,10 +234,24 @@ export const defaultTheme = { } }, + // Utilities utils: { divider: tokens.ash.c300, }, + // Onboarding + onboarding: { + bar: tokens.shade.c400, + barFilled: tokens.purple.c300, + divider: tokens.shade.c200, + card: tokens.shade.c800, + cardHover: tokens.shade.c700, + border: tokens.shade.c600, + good: tokens.purple.c100, + best: tokens.semantic.yellow.c100, + link: tokens.purple.c100, + }, + // Error page errors: { card: tokens.shade.c800, diff --git a/themes/list/blue.ts b/themes/list/blue.ts index e5b73409..e10592dc 100644 --- a/themes/list/blue.ts +++ b/themes/list/blue.ts @@ -95,6 +95,10 @@ export default createTheme({ accentB: tokens.blue.c500 }, + modal: { + background: tokens.shade.c800, + }, + type: { logo: tokens.purple.c100, text: tokens.shade.c50, diff --git a/themes/list/gray.ts b/themes/list/gray.ts index a0b9b742..c0c434e8 100644 --- a/themes/list/gray.ts +++ b/themes/list/gray.ts @@ -95,6 +95,10 @@ export default createTheme({ accentB: tokens.blue.c500 }, + modal: { + background: tokens.shade.c800, + }, + type: { logo: tokens.purple.c100, text: tokens.shade.c50, diff --git a/themes/list/red.ts b/themes/list/red.ts index 89614632..b42b935f 100644 --- a/themes/list/red.ts +++ b/themes/list/red.ts @@ -95,6 +95,10 @@ export default createTheme({ accentB: tokens.blue.c500 }, + modal: { + background: tokens.shade.c800, + }, + type: { logo: tokens.purple.c100, text: tokens.shade.c50, diff --git a/themes/list/teal.ts b/themes/list/teal.ts index cbae4748..742f4a32 100644 --- a/themes/list/teal.ts +++ b/themes/list/teal.ts @@ -95,6 +95,10 @@ export default createTheme({ accentB: tokens.blue.c500 }, + modal: { + background: tokens.shade.c800, + }, + type: { logo: tokens.purple.c100, text: tokens.shade.c50,