mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-29 21:01:52 +01:00
Merge pull request #760 from movie-web/extension
Browser extension & onboarding
This commit is contained in:
commit
132be80f81
@ -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",
|
||||
|
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@ -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}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
5
src/backend/extension/compatibility.ts
Normal file
5
src/backend/extension/compatibility.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const allowedExtensionVersion = ["0.0.1"];
|
||||
|
||||
export function isAllowedExtensionVersion(version: string): boolean {
|
||||
return allowedExtensionVersion.includes(version);
|
||||
}
|
71
src/backend/extension/messaging.ts
Normal file
71
src/backend/extension/messaging.ts
Normal 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;
|
||||
}
|
68
src/backend/extension/plasmo.ts
Normal file
68
src/backend/extension/plasmo.ts
Normal 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 {}
|
||||
}
|
43
src/backend/extension/streams.ts
Normal file
43
src/backend/extension/streams.ts
Normal 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),
|
||||
});
|
||||
}
|
@ -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>>;
|
||||
|
@ -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;
|
||||
}
|
27
src/backend/providers/providers.ts
Normal file
27
src/backend/providers/providers.ts
Normal 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,
|
||||
});
|
||||
}
|
@ -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"
|
||||
|
25
src/components/layout/Stepper.tsx
Normal file
25
src/components/layout/Stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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} />
|
||||
|
@ -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}
|
||||
|
@ -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))
|
||||
|
@ -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),
|
||||
|
@ -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,13 +26,14 @@ const statusTextMap: Partial<Record<ScrapeCardProps["status"], string>> = {
|
||||
pending: "player.scraping.items.pending",
|
||||
};
|
||||
|
||||
const statusMap: Record<ScrapeCardProps["status"], StatusCircle["type"]> = {
|
||||
failure: "error",
|
||||
notfound: "noresult",
|
||||
pending: "loading",
|
||||
success: "success",
|
||||
waiting: "waiting",
|
||||
};
|
||||
const statusMap: Record<ScrapeCardProps["status"], StatusCircleProps["type"]> =
|
||||
{
|
||||
failure: "error",
|
||||
notfound: "noresult",
|
||||
pending: "loading",
|
||||
success: "success",
|
||||
waiting: "waiting",
|
||||
};
|
||||
|
||||
export function ScrapeItem(props: ScrapeItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
@ -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,18 +31,21 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
|
||||
|
||||
return (
|
||||
<div
|
||||
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",
|
||||
"text-video-scraping-noresult text-opacity-50":
|
||||
props.type === "waiting",
|
||||
"text-video-scraping-error bg-video-scraping-error":
|
||||
props.type === "error",
|
||||
"text-green-500 bg-green-500": props.type === "success",
|
||||
"text-video-scraping-noresult bg-video-scraping-noresult":
|
||||
props.type === "noresult",
|
||||
})}
|
||||
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",
|
||||
"text-video-scraping-noresult text-opacity-50":
|
||||
props.type === "waiting",
|
||||
"text-video-scraping-error bg-video-scraping-error":
|
||||
props.type === "error",
|
||||
"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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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");
|
||||
|
@ -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}
|
||||
|
18
src/components/utils/ErrorLine.tsx
Normal file
18
src/components/utils/ErrorLine.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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`,
|
||||
|
@ -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);
|
||||
},
|
||||
[
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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">
|
||||
|
28
src/pages/layouts/MinimalPageLayout.tsx
Normal file
28
src/pages/layouts/MinimalPageLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
102
src/pages/onboarding/Onboarding.tsx
Normal file
102
src/pages/onboarding/Onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
152
src/pages/onboarding/OnboardingExtension.tsx
Normal file
152
src/pages/onboarding/OnboardingExtension.tsx
Normal 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>
|
||||
);
|
||||
}
|
80
src/pages/onboarding/OnboardingProxy.tsx
Normal file
80
src/pages/onboarding/OnboardingProxy.tsx
Normal 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>
|
||||
);
|
||||
}
|
37
src/pages/onboarding/onboardingHooks.ts
Normal file
37
src/pages/onboarding/onboardingHooks.ts
Normal 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;
|
||||
}
|
91
src/pages/onboarding/utils.tsx
Normal file
91
src/pages/onboarding/utils.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
67
src/pages/parts/settings/PreferencesPart.tsx
Normal file
67
src/pages/parts/settings/PreferencesPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
195
src/pages/parts/settings/SetupPart.tsx
Normal file
195
src/pages/parts/settings/SetupPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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",
|
||||
|
@ -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 />} />
|
||||
|
@ -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(",")
|
||||
|
@ -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]);
|
||||
|
22
src/stores/onboarding/index.tsx
Normal file
22
src/stores/onboarding/index.tsx
Normal 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" },
|
||||
),
|
||||
);
|
@ -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,
|
||||
|
@ -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> = {
|
||||
|
24
src/stores/preferences/index.tsx
Normal file
24
src/stores/preferences/index.tsx
Normal 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",
|
||||
},
|
||||
),
|
||||
);
|
@ -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
23
src/utils/onboarding.ts
Normal 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;
|
||||
}
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user