Merge branch 'dev' into dev

This commit is contained in:
William Oldham 2024-01-22 15:45:38 +00:00 committed by GitHub
commit d38cb6b632
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 1501 additions and 157 deletions

View File

@ -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",

View File

@ -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}

View File

@ -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.<br /><br />Please note that you are downloading an HLS playlist, it is <bold>not recommended to download if you are not familiar with advanced streaming formats</bold>. Try different sources for different formats.",
"onAndroid": {
"1": "To download on Android, click the download button then, on the new page, <bold>tap and hold</bold> on the video, then select <bold>save</bold>.",
"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.</0>",
"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</1>"
}
}
},
"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. <bold>Open the extension through your browsers extension menu</bold> 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"
}
}
}

View File

@ -0,0 +1,5 @@
const allowedExtensionVersion = ["0.0.1"];
export function isAllowedExtensionVersion(version: string): boolean {
return allowedExtensionVersion.includes(version);
}

View File

@ -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<MessageKey extends keyof MessagesMetadata>(
message: MessageKey,
payload: MessagesMetadata[MessageKey]["req"] | undefined = undefined,
timeout: number = -1,
) {
return new Promise<MessagesMetadata[MessageKey]["res"] | null>((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<T>(
ops: MessagesMetadata["makeRequest"]["req"],
): Promise<ExtensionMakeRequestResponse<T> | null> {
return sendMessage("makeRequest", ops);
}
export async function setDomainRule(
ops: MessagesMetadata["prepareStream"]["req"],
): Promise<MessagesMetadata["prepareStream"]["res"] | null> {
return sendMessage("prepareStream", ops);
}
export async function sendPage(
ops: MessagesMetadata["openPage"]["req"],
): Promise<MessagesMetadata["openPage"]["res"] | null> {
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<boolean> {
const info = await extensionInfo();
if (!info?.success) return false;
const allowedVersion = isAllowedExtensionVersion(info.version);
if (!allowedVersion) return false;
return info.allowed && info.hasPermission;
}

View File

@ -0,0 +1,68 @@
export interface ExtensionBaseRequest {}
export type ExtensionBaseResponse<T = object> =
| ({
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<string, string>;
body?: string | FormData | URLSearchParams | Record<string, any>;
}
export type ExtensionMakeRequestResponse<T> = ExtensionBaseResponse<{
response: {
statusCode: number;
headers: Record<string, string>;
finalUrl: string;
body: T;
};
}>;
export interface ExtensionPrepareStreamRequest extends ExtensionBaseRequest {
ruleId: number;
targetDomains: string[];
requestHeaders?: Record<string, string>;
responseHeaders?: Record<string, string>;
}
export interface MmMetadata {
hello: {
req: ExtensionBaseRequest;
res: ExtensionHelloResponse;
};
makeRequest: {
req: ExtensionMakeRequest;
res: ExtensionMakeRequestResponse<any>;
};
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 {}
}

View File

@ -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<string, string> {
const headers: Record<string, string> = {};
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),
});
}

View File

@ -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<T> = Parameters<typeof ofetch<T, any>>;
type R<T> = ReturnType<typeof ofetch<T, any>>;

View File

@ -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<string, string>,
): 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<any>({
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;
}

View File

@ -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,
});
}

View File

@ -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 (
<button
type="button"

View File

@ -0,0 +1,25 @@
export interface StepperProps {
current: number;
steps: number;
className?: string;
}
export function Stepper(props: StepperProps) {
const percentage = (props.current / props.steps) * 100;
return (
<div className={props.className}>
<p className="mb-2">
{props.current}/{props.steps}
</p>
<div className="max-w-full h-1 w-32 bg-onboarding-bar rounded-full overflow-hidden">
<div
className="h-full bg-onboarding-barFilled transition-[width] rounded-full"
style={{
width: `${percentage.toFixed(0)}%`,
}}
/>
</div>
</div>
);
}

View File

@ -1,3 +1,4 @@
import classNames from "classnames";
import { ReactNode } from "react";
interface ThinContainerProps {
@ -16,3 +17,16 @@ export function ThinContainer(props: ThinContainerProps) {
</div>
);
}
export function CenterContainer(props: ThinContainerProps) {
return (
<div
className={classNames(
"min-h-screen w-full flex justify-center p-8 py-24 items-center",
props.classNames,
)}
>
<div className="w-[700px] max-w-full">{props.children}</div>
</div>
);
}

View File

@ -19,7 +19,7 @@ export function useModal(id: string) {
export function ModalCard(props: { children?: ReactNode }) {
return (
<div className="w-full max-w-[30rem] m-4">
<div className="w-full bg-dropdown-background rounded-xl p-8 pointer-events-auto">
<div className="w-full bg-modal-background rounded-xl p-8 pointer-events-auto">
{props.children}
</div>
</div>

View File

@ -134,7 +134,7 @@ export function CaptionsView({ id }: { id: string }) {
[selectCaptionById, setCurrentlyDownloading],
);
const content = subtitleList.map((v, i) => {
const content = subtitleList.map((v) => {
return (
<CaptionOption
// key must use index to prevent url collisions

View File

@ -27,6 +27,7 @@ function StyleTrans(props: { k: string }) {
i18nKey={props.k}
components={{
bold: <Menu.Highlight />,
br: <br />,
ios_share: (
<Icon icon={Icons.IOS_SHARE} className="inline-block text-xl -mb-1" />
),
@ -123,24 +124,6 @@ export function DownloadView({ id }: { id: string }) {
);
}
export function CantDownloadView({ id }: { id: string }) {
const router = useOverlayRouter(id);
const { t } = useTranslation();
return (
<>
<Menu.BackLink onClick={() => router.navigate("/")}>
{t("player.menus.downloads.title")}
</Menu.BackLink>
<Menu.Section>
<Menu.Paragraph>
<StyleTrans k="player.menus.downloads.hlsExplanation" />
</Menu.Paragraph>
</Menu.Section>
</>
);
}
function AndroidExplanationView({ id }: { id: string }) {
const router = useOverlayRouter(id);
const { t } = useTranslation();
@ -202,11 +185,6 @@ export function DownloadRoutes({ id }: { id: string }) {
<DownloadView id={id} />
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage id={id} path="/download/unable" width={343} height={440}>
<Menu.CardWithScrollable>
<CantDownloadView id={id} />
</Menu.CardWithScrollable>
</OverlayPage>
<OverlayPage id={id} path="/download/ios" width={343} height={440}>
<Menu.CardWithScrollable>
<IOSExplanationView id={id} />

View File

@ -147,7 +147,7 @@ export function SourceSelectionView({
<Menu.BackLink onClick={() => router.navigate("/")}>
{t("player.menus.sources.title")}
</Menu.BackLink>
<Menu.Section>
<Menu.Section className="pb-4">
{sources.map((v) => (
<SelectableLink
key={v.id}

View File

@ -41,6 +41,7 @@ function qualityToHlsLevel(quality: SourceQuality): number | null {
);
return found ? +found[0] : null;
}
function hlsLevelsToQualities(levels: Level[]): SourceQuality[] {
return levels
.map((v) => hlsLevelToQuality(v))

View File

@ -5,6 +5,8 @@ import {
} from "@movie-web/providers";
import { useAsyncFn } from "react-use";
import { isExtensionActiveCached } from "@/backend/extension/messaging";
import { prepareStream } from "@/backend/extension/streams";
import {
connectServerSideEvents,
makeProviderUrl,
@ -13,12 +15,13 @@ import {
scrapeSourceOutputToProviderMetric,
useReportProviders,
} from "@/backend/helpers/report";
import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers";
import { getProviders } from "@/backend/providers/providers";
import { convertProviderCaption } from "@/components/player/utils/captions";
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { metaToScrapeMedia } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { getLoadbalancedProviderApiUrl, providers } from "@/utils/providers";
export function useEmbedScraping(
routerId: string,
@ -47,7 +50,7 @@ export function useEmbedScraping(
);
result = await conn.promise();
} else {
result = await providers.runEmbedScraper({
result = await getProviders().runEmbedScraper({
id: embedId,
url,
});
@ -70,6 +73,7 @@ export function useEmbedScraping(
report([
scrapeSourceOutputToProviderMetric(meta, sourceId, null, "success", null),
]);
if (isExtensionActiveCached()) await prepareStream(result.stream[0]);
setSourceId(sourceId);
setCaption(null);
setSource(
@ -111,7 +115,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
);
result = await conn.promise();
} else {
result = await providers.runSourceScraper({
result = await getProviders().runSourceScraper({
id: sourceId,
media: scrapeMedia,
});
@ -130,6 +134,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
]);
if (result.stream) {
if (isExtensionActiveCached()) await prepareStream(result.stream[0]);
setCaption(null);
setSource(
convertRunoutputToSource({ stream: result.stream[0] }),
@ -155,7 +160,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
);
embedResult = await conn.promise();
} else {
embedResult = await providers.runEmbedScraper({
embedResult = await getProviders().runEmbedScraper({
id: result.embeds[0].embedId,
url: result.embeds[0].url,
});
@ -186,6 +191,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
]);
setSourceId(sourceId);
setCaption(null);
if (isExtensionActiveCached()) await prepareStream(embedResult.stream[0]);
setSource(
convertRunoutputToSource({ stream: embedResult.stream[0] }),
convertProviderCaption(embedResult.stream[0].captions),

View File

@ -2,7 +2,10 @@ import classNames from "classnames";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { StatusCircle } from "@/components/player/internals/StatusCircle";
import {
StatusCircle,
StatusCircleProps,
} from "@/components/player/internals/StatusCircle";
import { Transition } from "@/components/utils/Transition";
export interface ScrapeItemProps {
@ -23,7 +26,8 @@ const statusTextMap: Partial<Record<ScrapeCardProps["status"], string>> = {
pending: "player.scraping.items.pending",
};
const statusMap: Record<ScrapeCardProps["status"], StatusCircle["type"]> = {
const statusMap: Record<ScrapeCardProps["status"], StatusCircleProps["type"]> =
{
failure: "error",
notfound: "noresult",
pending: "loading",

View File

@ -4,23 +4,24 @@ import classNames from "classnames";
import { Icon, Icons } from "@/components/Icon";
import { Transition } from "@/components/utils/Transition";
export interface StatusCircle {
export interface StatusCircleProps {
type: "loading" | "success" | "error" | "noresult" | "waiting";
percentage?: number;
className?: string;
}
export interface StatusCircleLoading extends StatusCircle {
export interface StatusCircleLoading extends StatusCircleProps {
type: "loading";
percentage: number;
}
function statusIsLoading(
props: StatusCircle | StatusCircleLoading,
props: StatusCircleProps | StatusCircleLoading,
): props is StatusCircleLoading {
return props.type === "loading";
}
export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
export function StatusCircle(props: StatusCircleProps | StatusCircleLoading) {
const [spring] = useSpring(
() => ({
percentage: statusIsLoading(props) ? props.percentage : 0,
@ -30,7 +31,8 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
return (
<div
className={classNames({
className={classNames(
{
"p-0.5 border-current border-[3px] rounded-full h-6 w-6 relative transition-colors":
true,
"text-video-scraping-loading": props.type === "loading",
@ -41,7 +43,9 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
"text-green-500 bg-green-500": props.type === "success",
"text-video-scraping-noresult bg-video-scraping-noresult":
props.type === "noresult",
})}
},
props.className,
)}
>
<Transition animation="fade" show={statusIsLoading(props)}>
<svg
@ -65,13 +69,13 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
</Transition>
<Transition animation="fade" show={props.type === "error"}>
<Icon
className="absolute inset-0 flex items-center justify-center text-white"
className="absolute inset-0 flex items-center justify-center text-background-main"
icon={Icons.X}
/>
</Transition>
<Transition animation="fade" show={props.type === "success"}>
<Icon
className="absolute inset-0 flex items-center text-xs justify-center text-white"
className="absolute inset-0 flex items-center text-sm justify-center text-background-main"
icon={Icons.CHECKMARK}
/>
</Transition>

View File

@ -5,6 +5,7 @@ import { playerStatus } from "@/stores/player/slices/source";
import { ThumbnailImage } from "@/stores/player/slices/thumbnails";
import { usePlayerStore } from "@/stores/player/store";
import { LoadableSource, selectQuality } from "@/stores/player/utils/qualities";
import { usePreferencesStore } from "@/stores/preferences";
import { processCdnLink } from "@/utils/cdn";
import { isSafari } from "@/utils/detectFeatures";
@ -128,6 +129,7 @@ export function ThumbnailScraper() {
const resetImages = usePlayerStore((s) => s.thumbnails.resetImages);
const meta = usePlayerStore((s) => s.meta);
const source = usePlayerStore((s) => s.source);
const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails);
const workerRef = useRef<ThumnbnailWorker | null>(null);
// object references dont always trigger changes, so we serialize it to detect *any* change
@ -159,8 +161,8 @@ export function ThumbnailScraper() {
// start worker with the stream
useEffect(() => {
startRef.current();
}, [sourceSeralized]);
if (enableThumbnails) startRef.current();
}, [sourceSeralized, enableThumbnails]);
// destroy worker on unmount
useEffect(() => {
@ -183,8 +185,8 @@ export function ThumbnailScraper() {
workerRef.current.destroy();
workerRef.current = null;
}
startRef.current();
}, [serializedMeta, sourceSeralized, status]);
if (enableThumbnails) startRef.current();
}, [serializedMeta, sourceSeralized, status, enableThumbnails]);
return null;
}

View File

@ -28,6 +28,7 @@ export function convertRunoutputToSource(out: {
return {
type: "hls",
url: out.stream.playlist,
preferredHeaders: out.stream.preferredHeaders,
};
}
if (out.stream.type === "file") {
@ -49,6 +50,7 @@ export function convertRunoutputToSource(out: {
return {
type: "file",
qualities,
preferredHeaders: out.stream.preferredHeaders,
};
}
throw new Error("unrecognized type");

View File

@ -1,3 +1,5 @@
import classNames from "classnames";
import { TextInputControl } from "./TextInputControl";
export function AuthInputBox(props: {
@ -8,9 +10,10 @@ export function AuthInputBox(props: {
placeholder?: string;
onChange?: (data: string) => void;
passwordToggleable?: boolean;
className?: string;
}) {
return (
<div className="space-y-3">
<div className={classNames("space-y-3", props.className)}>
{props.label ? (
<p className="font-bold text-white">{props.label}</p>
) : null}

View File

@ -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 (
<p
className={classNames(
"inline-flex items-center text-type-danger",
props.className,
)}
>
<Icon icon={Icons.WARNING} className="text-xl mr-4" />
{props.children}
</p>
);
}

View File

@ -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) {
<div
className="absolute inset-0 opacity-10"
style={{
background: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`,
background: `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`,

View File

@ -5,12 +5,15 @@ import {
} from "@movie-web/providers";
import { RefObject, useCallback, useEffect, useRef, useState } from "react";
import { isExtensionActiveCached } from "@/backend/extension/messaging";
import { prepareStream } from "@/backend/extension/streams";
import {
connectServerSideEvents,
getCachedMetadata,
makeProviderUrl,
} from "@/backend/helpers/providerApi";
import { getLoadbalancedProviderApiUrl, providers } from "@/utils/providers";
import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers";
import { getProviders } from "@/backend/providers/providers";
export interface ScrapingItems {
id: string;
@ -168,12 +171,14 @@ export function useScrape() {
conn.on("update", updateEvent);
conn.on("discoverEmbeds", discoverEmbedsEvent);
const sseOutput = await conn.promise();
if (sseOutput && isExtensionActiveCached())
await prepareStream(sseOutput.stream);
return getResult(sseOutput === "" ? null : sseOutput);
}
if (!providers) return null;
startScrape();
const providers = getProviders();
const output = await providers.runAll({
media,
events: {
@ -183,6 +188,8 @@ export function useScrape() {
discoverEmbeds: discoverEmbedsEvent,
},
});
if (output && isExtensionActiveCached())
await prepareStream(output.stream);
return getResult(output);
},
[

View File

@ -49,6 +49,7 @@ export function useSettingsState(
icon: string;
}
| undefined,
enableThumbnails: boolean,
) {
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
useDerived(proxyUrls);
@ -71,6 +72,12 @@ export function useSettingsState(
] = useDerived(deviceName);
const [profileState, setProfileState, resetProfile, profileChanged] =
useDerived(profile);
const [
enableThumbnailsState,
setEnableThumbnailsState,
resetEnableThumbnails,
enableThumbnailsChanged,
] = useDerived(enableThumbnails);
function reset() {
resetTheme();
@ -80,6 +87,7 @@ export function useSettingsState(
resetBackendUrl();
resetDeviceName();
resetProfile();
resetEnableThumbnails();
}
const changed =
@ -89,7 +97,8 @@ export function useSettingsState(
deviceNameChanged ||
backendUrlChanged ||
proxyUrlsChanged ||
profileChanged;
profileChanged ||
enableThumbnailsChanged;
return {
reset,
@ -129,5 +138,10 @@ export function useSettingsState(
set: setProfileState,
changed: profileChanged,
},
enableThumbnails: {
state: enableThumbnailsState,
set: setEnableThumbnailsState,
changed: enableThumbnailsChanged,
},
};
}

View File

@ -1,6 +1,12 @@
import { RunOutput } from "@movie-web/providers";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Navigate,
useLocation,
useNavigate,
useParams,
} from "react-router-dom";
import { useAsync } from "react-use";
import { usePlayer } from "@/components/player/hooks/usePlayer";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
@ -15,9 +21,10 @@ import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart";
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
import { useLastNonPlayerLink } from "@/stores/history";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { needsOnboarding } from "@/utils/onboarding";
import { parseTimestamp } from "@/utils/timestamp";
export function PlayerView() {
export function RealPlayerView() {
const navigate = useNavigate();
const params = useParams<{
media: string;
@ -109,4 +116,25 @@ export function PlayerView() {
);
}
export function PlayerView() {
const loc = useLocation();
const { loading, error, value } = useAsync(() => {
return needsOnboarding();
});
if (error) throw new Error("Failed to detect onboarding");
if (loading) return null;
if (value)
return (
<Navigate
replace
to={{
pathname: "/onboarding",
search: `redirect=${encodeURIComponent(loc.pathname)}`,
}}
/>
);
return <RealPlayerView />;
}
export default PlayerView;

View File

@ -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() {
<RegisterCalloutPart />
)}
</div>
<div id="settings-locale" className="mt-48">
<LocalePart
<div id="settings-preferences" className="mt-48">
<PreferencesPart
language={state.appLanguage.state}
setLanguage={state.appLanguage.set}
enableThumbnails={state.enableThumbnails.state}
setEnableThumbnails={state.enableThumbnails.set}
/>
</div>
<div id="settings-appearance" className="mt-48">

View File

@ -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 (
<div
className="bg-background-main min-h-screen"
style={{
backgroundImage:
"linear-gradient(to bottom, var(--tw-gradient-from), var(--tw-gradient-to) 800px)",
}}
>
<BlurEllipsis />
{/* Main page */}
<div className="fixed px-7 py-5 left-0 top-0">
<Link
className="block tabbable rounded-full text-xs ssm:text-base"
to="/"
>
<BrandPill clickable />
</Link>
</div>
<div className="min-h-screen">{props.children}</div>
</div>
);
}

View File

@ -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 (
<div className={classNames("w-full grid justify-center", props.className)}>
<div className="w-px h-10 bg-onboarding-divider" />
</div>
);
}
export function OnboardingPage() {
const navigate = useNavigateOnboarding();
const skipModal = useModal("skip");
const { completeAndRedirect } = useRedirectBack();
const { t } = useTranslation();
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.onboarding" />
<Modal id={skipModal.id}>
<ModalCard>
<Heading1 className="!mt-0 !mb-4 !text-2xl">
{t("onboarding.defaultConfirm.title")}
</Heading1>
<Paragraph className="!mt-1 !mb-12">
{t("onboarding.defaultConfirm.description")}
</Paragraph>
<div className="flex items-end justify-between">
<Button theme="secondary" onClick={skipModal.hide}>
{t("onboarding.defaultConfirm.cancel")}
</Button>
<Button theme="purple" onClick={() => completeAndRedirect()}>
{t("onboarding.defaultConfirm.confirm")}
</Button>
</div>
</ModalCard>
</Modal>
<CenterContainer>
<Stepper steps={2} current={1} className="mb-12" />
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
{t("onboarding.start.title")}
</Heading2>
<Paragraph className="max-w-[320px]">
{t("onboarding.start.explainer")}
</Paragraph>
<div className="w-full grid grid-cols-[1fr,auto,1fr] gap-3">
<Card onClick={() => navigate("/onboarding/proxy")}>
<CardContent
colorClass="!text-onboarding-good"
title={t("onboarding.start.options.proxy.title")}
subtitle={t("onboarding.start.options.proxy.quality")}
description={t("onboarding.start.options.proxy.description")}
>
<Link>{t("onboarding.start.options.proxy.action")}</Link>
</CardContent>
</Card>
<div className="grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
<VerticalLine className="items-end" />
<span className="text-xs uppercase font-bold">or</span>
<VerticalLine />
</div>
<Card onClick={() => navigate("/onboarding/extension")}>
<CardContent
colorClass="!text-onboarding-best"
title={t("onboarding.start.options.extension.title")}
subtitle={t("onboarding.start.options.extension.quality")}
description={t("onboarding.start.options.extension.description")}
>
<Link>{t("onboarding.start.options.extension.action")}</Link>
</CardContent>
</Card>
</div>
<p className="text-center mt-12">
<Trans i18nKey="onboarding.start.options.default.text">
<br />
<a
onClick={skipModal.show}
type="button"
className="text-onboarding-link hover:opacity-75 cursor-pointer"
/>
</Trans>
</p>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@ -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<ExtensionStatus> {
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 = (
<>
<Loading />
<p>{t("onboarding.extension.status.loading")}</p>
</>
);
if (props.status === "disallowed" || props.status === "noperms")
content = (
<>
<p>{t("onboarding.extension.status.disallowed")}</p>
<Button
onClick={() => {
sendPage({
page: "PermissionGrant",
redirectUrl: window.location.href,
});
}}
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("onboarding.extension.status.disallowedAction")}
</Button>
</>
);
else if (props.status === "failed")
content = <p>{t("onboarding.extension.status.failed")}</p>;
else if (props.status === "outdated")
content = <p>{t("onboarding.extension.status.outdated")}</p>;
else if (props.status === "success")
content = (
<p className="flex items-center">
<Icon icon={Icons.CHECKMARK} className="text-type-success mr-4" />
{t("onboarding.extension.status.success")}
</p>
);
return (
<>
<Card>
<div className="flex py-6 flex-col space-y-2 items-center justify-center">
{content}
</div>
</Card>
<Card className="mt-4">
<div className="flex items-center space-x-7">
<Icon icon={Icons.WARNING} className="text-type-danger text-2xl" />
<p className="flex-1">
<Trans
i18nKey="onboarding.extension.extensionHelp"
components={{
bold: <span className="text-white" />,
}}
/>
</p>
</div>
</Card>
</>
);
}
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 (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.onboarding" />
<CenterContainer>
<Stepper steps={2} current={2} className="mb-12" />
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
{t("onboarding.extension.title")}
</Heading2>
<Paragraph className="max-w-[320px] mb-4">
{t("onboarding.extension.explainer")}
</Paragraph>
<Link href="https://google.com" target="_blank" className="mb-12">
{t("onboarding.extension.link")}
</Link>
<ExtensionStatus status={value ?? "unknown"} loading={loading} />
<div className="flex justify-between items-center mt-8">
<Button onClick={() => navigate("/onboarding")} theme="secondary">
{t("onboarding.extension.back")}
</Button>
{value === "success" ? (
<Button onClick={() => exec(true)} theme="purple">
{t("onboarding.extension.submit")}
</Button>
) : null}
</div>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@ -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 (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.onboarding" />
<CenterContainer>
<Stepper steps={2} current={2} className="mb-12" />
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
{t("onboarding.proxy.title")}
</Heading2>
<Paragraph className="max-w-[320px] !mb-5">
{t("onboarding.proxy.explainer")}
</Paragraph>
<Link>{t("onboarding.proxy.link")}</Link>
<div className="w-[400px] max-w-full mt-14 mb-28">
<AuthInputBox
label={t("onboarding.proxy.input.label")}
value={url}
onChange={setUrl}
placeholder={t("onboarding.proxy.input.placeholder")}
className="mb-4"
/>
{error ? <ErrorLine>{t(error.message)}</ErrorLine> : null}
</div>
<Divider />
<div className="flex justify-between">
<Button theme="secondary" onClick={() => navigate("/onboarding")}>
{t("onboarding.proxy.back")}
</Button>
<Button theme="purple" loading={loading} onClick={test}>
{t("onboarding.proxy.submit")}
</Button>
</div>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@ -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;
}

View File

@ -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 (
<div
className={classNames(
{
"bg-onboarding-card duration-300 border border-onboarding-border rounded-lg p-7":
true,
"hover:bg-onboarding-cardHover transition-colors cursor-pointer":
!!props.onClick,
},
props.className,
)}
onClick={props.onClick}
>
{props.children}
</div>
);
}
export function CardContent(props: {
title: ReactNode;
description: ReactNode;
subtitle: ReactNode;
colorClass: string;
children?: React.ReactNode;
}) {
return (
<div className="grid grid-rows-[1fr,auto] h-full">
<div>
<Icon
icon={Icons.RISING_STAR}
className={classNames("text-4xl mb-8 block", props.colorClass)}
/>
<Heading3
className={classNames(
"!mt-0 !mb-0 !text-xs uppercase",
props.colorClass,
)}
>
{props.subtitle}
</Heading3>
<Heading2 className="!mb-0 !mt-1 !text-base">{props.title}</Heading2>
<Paragraph className="max-w-[320px] !my-4">
{props.description}
</Paragraph>
</div>
<div>{props.children}</div>
</div>
);
}
export function Link(props: {
children?: React.ReactNode;
to?: string;
href?: string;
className?: string;
target?: "_blank";
}) {
const navigate = useNavigate();
return (
<a
onClick={() => {
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}
<Icon
icon={Icons.ARROW_RIGHT}
className="group-hover:translate-x-0.5 transition-transform text-xl group-active:translate-x-0"
/>
</a>
);
}

View File

@ -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<typeof decodeTMDBId> = 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 (
<ErrorLayout>
<ErrorContainer>
<IconPill icon={Icons.WAND}>
{t("player.metadata.extensionPermission.badge")}
</IconPill>
<Title>{t("player.metadata.extensionPermission.title")}</Title>
<Paragraph>{t("player.metadata.extensionPermission.text")}</Paragraph>
<Button
onClick={() => {
sendPage({
page: "PermissionGrant",
redirectUrl: window.location.href,
});
}}
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.metadata.extensionPermission.button")}
</Button>
</ErrorContainer>
</ErrorLayout>
);
}
if (error && error.message === "dmca") {
return (
<ErrorLayout>
<ErrorContainer>
<IconPill icon={Icons.DRAGON}>Removed</IconPill>
<Title>Media has been removed</Title>
<Paragraph>
This media is no longer available due to a takedown notice or
copyright claim.
</Paragraph>
<IconPill icon={Icons.DRAGON}>
{t("player.metadata.dmca.badge")}
</IconPill>
<Title>{t("player.metadata.dmca.title")}</Title>
<Paragraph>{t("player.metadata.dmca.text")}</Paragraph>
<Button
href="/"
theme="purple"

View File

@ -9,6 +9,7 @@ import { MwLink } from "@/components/text/Link";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { Divider } from "@/components/utils/Divider";
import { Heading1 } from "@/components/utils/Text";
import { SetupPart } from "@/pages/parts/settings/SetupPart";
interface ProxyEditProps {
proxyUrls: string[] | null;
@ -156,6 +157,7 @@ export function ConnectionsPart(props: BackendEditProps & ProxyEditProps) {
<div>
<Heading1 border>{t("settings.connections.title")}</Heading1>
<div className="space-y-6">
<SetupPart />
<ProxyEdit
proxyUrls={props.proxyUrls}
setProxyUrls={props.setProxyUrls}

View File

@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { FlagIcon } from "@/components/FlagIcon";
import { Dropdown } from "@/components/form/Dropdown";
import { Heading1 } from "@/components/utils/Text";
import { appLanguageOptions } from "@/setup/i18n";
import { getLocaleInfo, sortLangCodes } from "@/utils/language";
export function LocalePart(props: {
language: string;
setLanguage: (l: string) => void;
}) {
const { t } = useTranslation();
const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code));
const options = appLanguageOptions
.sort((a, b) => sorted.indexOf(a.code) - sorted.indexOf(b.code))
.map((opt) => ({
id: opt.code,
name: `${opt.name}${opt.nativeName ? `${opt.nativeName}` : ""}`,
leftIcon: <FlagIcon langCode={opt.code} />,
}));
const selected = options.find(
(item) => item.id === getLocaleInfo(props.language)?.code,
);
return (
<div>
<Heading1 border>{t("settings.locale.title")}</Heading1>
<p className="text-white font-bold mb-3">
{t("settings.locale.language")}
</p>
<p className="max-w-[20rem] font-medium">
{t("settings.locale.languageDescription")}
</p>
<Dropdown
options={options}
selectedItem={selected || options[0]}
setSelectedItem={(opt) => props.setLanguage(opt.id)}
/>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { useTranslation } from "react-i18next";
import { Toggle } from "@/components/buttons/Toggle";
import { FlagIcon } from "@/components/FlagIcon";
import { Dropdown } from "@/components/form/Dropdown";
import { Heading1 } from "@/components/utils/Text";
import { appLanguageOptions } from "@/setup/i18n";
import { getLocaleInfo, sortLangCodes } from "@/utils/language";
export function PreferencesPart(props: {
language: string;
setLanguage: (l: string) => void;
enableThumbnails: boolean;
setEnableThumbnails: (v: boolean) => void;
}) {
const { t } = useTranslation();
const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code));
const options = appLanguageOptions
.sort((a, b) => sorted.indexOf(a.code) - sorted.indexOf(b.code))
.map((opt) => ({
id: opt.code,
name: `${opt.name}${opt.nativeName ? `${opt.nativeName}` : ""}`,
leftIcon: <FlagIcon langCode={opt.code} />,
}));
const selected = options.find(
(item) => item.id === getLocaleInfo(props.language)?.code,
);
return (
<div className="space-y-12">
<Heading1 border>{t("settings.preferences.title")}</Heading1>
<div>
<p className="text-white font-bold mb-3">
{t("settings.preferences.language")}
</p>
<p className="max-w-[20rem] font-medium">
{t("settings.preferences.languageDescription")}
</p>
<Dropdown
options={options}
selectedItem={selected || options[0]}
setSelectedItem={(opt) => props.setLanguage(opt.id)}
/>
</div>
<div>
<p className="text-white font-bold mb-3">
{t("settings.preferences.thumbnail")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.preferences.thumbnailDescription")}
</p>
<div
onClick={() => props.setEnableThumbnails(!props.enableThumbnails)}
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
>
<Toggle enabled={props.enableThumbnails} />
<p className="flex-1 text-white font-bold">
{t("settings.preferences.thumbnailLabel")}
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,195 @@
import classNames from "classnames";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useAsync } from "react-use";
import { isExtensionActive } from "@/backend/extension/messaging";
import { singularProxiedFetch } from "@/backend/helpers/fetch";
import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon";
import { SettingsCard } from "@/components/layout/SettingsCard";
import {
StatusCircle,
StatusCircleProps,
} from "@/components/player/internals/StatusCircle";
import { Heading3 } from "@/components/utils/Text";
import { useAuthStore } from "@/stores/auth";
const testUrl = "https://postman-echo.com/get";
type Status = "success" | "unset" | "error";
type SetupData = {
extension: Status;
proxy: Status;
defaultProxy: Status;
};
function testProxy(url: string) {
return new Promise<void>((resolve, reject) => {
setTimeout(() => reject(new Error("Timed out!")), 1000);
singularProxiedFetch(url, testUrl, {})
.then((res) => {
if (res.url !== testUrl) return reject(new Error("Not a proxy"));
resolve();
})
.catch(reject);
});
}
function useIsSetup() {
const proxyUrls = useAuthStore((s) => s.proxySet);
const { loading, value } = useAsync(async (): Promise<SetupData> => {
const extensionStatus: Status = (await isExtensionActive())
? "success"
: "unset";
let proxyStatus: Status = "unset";
if (proxyUrls && proxyUrls.length > 0) {
try {
await testProxy(proxyUrls[0]);
proxyStatus = "success";
} catch {
proxyStatus = "error";
}
}
return {
extension: extensionStatus,
proxy: proxyStatus,
defaultProxy: "success",
};
}, [proxyUrls]);
let globalState: Status = "unset";
if (value?.extension === "success" || value?.proxy === "success")
globalState = "success";
if (value?.proxy === "error" || value?.extension === "error")
globalState = "error";
return {
setupStates: value,
globalState,
loading,
};
}
function SetupCheckList(props: {
status: Status;
grey?: boolean;
highlight?: boolean;
children?: ReactNode;
}) {
const { t } = useTranslation();
const statusMap: Record<Status, StatusCircleProps["type"]> = {
error: "error",
success: "success",
unset: "noresult",
};
return (
<div className="flex items-start text-type-dimmed my-4">
<StatusCircle
type={statusMap[props.status]}
className={classNames({
"!text-video-scraping-noresult !bg-video-scraping-noresult opacity-50":
props.grey,
"scale-90 mr-3": true,
})}
/>
<div>
<p
className={classNames({
"!text-white": props.grey && props.highlight,
"!text-type-dimmed opacity-75": props.grey && !props.highlight,
"text-type-danger": props.status === "error",
"text-white": props.status === "success",
})}
>
{props.children}
</p>
{props.status === "error" ? (
<p className="max-w-96">
{t("settings.connections.setup.itemError")}
</p>
) : null}
</div>
</div>
);
}
export function SetupPart() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loading, setupStates, globalState } = useIsSetup();
if (loading || !setupStates) return <p>Loading states...</p>; // TODO proper loading screen
const textLookupMap: Record<
Status,
{ title: string; desc: string; button: string }
> = {
error: {
title: "settings.connections.setup.errorStatus.title",
desc: "settings.connections.setup.errorStatus.description",
button: "settings.connections.setup.redoSetup",
},
success: {
title: "settings.connections.setup.successStatus.title",
desc: "settings.connections.setup.successStatus.description",
button: "settings.connections.setup.redoSetup",
},
unset: {
title: "settings.connections.setup.unsetStatus.title",
desc: "settings.connections.setup.unsetStatus.description",
button: "settings.connections.setup.doSetup",
},
};
return (
<SettingsCard>
<div className="flex items-start gap-4">
<div>
<div
className={classNames({
"rounded-full h-12 w-12 flex bg-opacity-15 justify-center items-center":
true,
"text-type-success bg-type-success": globalState === "success",
"text-type-danger bg-type-danger":
globalState === "error" || globalState === "unset",
})}
>
<Icon
icon={globalState === "success" ? Icons.CHECKMARK : Icons.X}
className="text-xl"
/>
</div>
</div>
<div className="flex-1">
<Heading3 className="!mb-3">
{t(textLookupMap[globalState].title)}
</Heading3>
<p className="max-w-[20rem] font-medium mb-6">
{t(textLookupMap[globalState].desc)}
</p>
<SetupCheckList status={setupStates.extension}>
{t("settings.connections.setup.items.extension")}
</SetupCheckList>
<SetupCheckList status={setupStates.proxy}>
{t("settings.connections.setup.items.proxy")}
</SetupCheckList>
<SetupCheckList
grey
highlight={globalState === "unset"}
status={setupStates.defaultProxy}
>
{t("settings.connections.setup.items.default")}
</SetupCheckList>
</div>
<div className="mt-5">
<Button theme="purple" onClick={() => navigate("/onboarding")}>
{t(textLookupMap[globalState].button)}
</Button>
</div>
</div>
</SettingsCard>
);
}

View File

@ -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",

View File

@ -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() {
<Route path="/register" element={<RegisterPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route
path="/onboarding/extension"
element={<OnboardingExtensionPage />}
/>
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
{shouldHaveDmcaPage() ? (
<Route path="/dmca" element={<DmcaPage />} />

View File

@ -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<string[]>;
HAS_ONBOARDING: boolean;
}
const env: Record<keyof Config, undefined | string> = {
@ -49,6 +51,7 @@ const env: Record<keyof Config, undefined | string> = {
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(",")

View File

@ -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]);

View File

@ -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<OnboardingStore>((set) => ({
completed: false,
setCompleted(v) {
set((s) => {
s.completed = v;
});
},
})),
{ name: "__MW::onboarding" },
),
);

View File

@ -118,6 +118,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
},
setSourceId(id) {
set((s) => {
s.status = playerStatus.PLAYING;
s.sourceId = id;
});
},
@ -155,6 +156,8 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (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<SourceSlice> = (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<SourceSlice> = (set, get) => ({
if (!selectedQuality) return;
set((s) => {
s.currentQuality = quality;
s.status = playerStatus.PLAYING;
s.interface.error = undefined;
});
store.display?.load({
source: selectedQuality,

View File

@ -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<Record<SourceQuality, SourceFileStream>>;
preferredHeaders?: Stream["preferredHeaders"];
}
| {
type: "hls";
url: string;
preferredHeaders?: Stream["preferredHeaders"];
};
const qualitySorting: Record<SourceQuality, number> = {

View File

@ -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<PreferencesStore>((set) => ({
enableThumbnails: false,
setEnableThumbnails(v) {
set((s) => {
s.enableThumbnails = v;
});
},
})),
{
name: "__MW::preferences",
},
),
);

View File

@ -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;

23
src/utils/onboarding.ts Normal file
View File

@ -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<boolean> {
// 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;
}

View File

@ -138,6 +138,11 @@ export const defaultTheme = {
accentB: tokens.blue.c500,
},
// Modals
modal: {
background: tokens.shade.c800,
},
// typography
type: {
logo: tokens.purple.c100,
@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,