mirror of
https://github.com/movie-web/movie-web.git
synced 2025-01-01 19:31:51 +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",
|
"@formkit/auto-animate": "^0.8.1",
|
||||||
"@headlessui/react": "^1.7.17",
|
"@headlessui/react": "^1.7.17",
|
||||||
"@ladjs/country-language": "^1.0.3",
|
"@ladjs/country-language": "^1.0.3",
|
||||||
"@movie-web/providers": "^2.0.5",
|
"@movie-web/providers": "^2.1.0",
|
||||||
"@noble/hashes": "^1.3.3",
|
"@noble/hashes": "^1.3.3",
|
||||||
|
"@plasmohq/messaging": "^0.6.1",
|
||||||
"@react-spring/web": "^9.7.3",
|
"@react-spring/web": "^9.7.3",
|
||||||
"@scure/bip39": "^1.2.2",
|
"@scure/bip39": "^1.2.2",
|
||||||
"@sozialhelden/ietf-language-tags": "^5.4.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
|
specifier: ^1.0.3
|
||||||
version: 1.0.3
|
version: 1.0.3
|
||||||
'@movie-web/providers':
|
'@movie-web/providers':
|
||||||
specifier: ^2.0.5
|
specifier: ^2.1.0
|
||||||
version: 2.0.5
|
version: 2.1.0
|
||||||
'@noble/hashes':
|
'@noble/hashes':
|
||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
version: 1.3.3
|
version: 1.3.3
|
||||||
|
'@plasmohq/messaging':
|
||||||
|
specifier: ^0.6.1
|
||||||
|
version: 0.6.1(react@18.2.0)
|
||||||
'@react-spring/web':
|
'@react-spring/web':
|
||||||
specifier: ^9.7.3
|
specifier: ^9.7.3
|
||||||
version: 9.7.3(react-dom@18.2.0)(react@18.2.0)
|
version: 9.7.3(react-dom@18.2.0)(react@18.2.0)
|
||||||
@ -1921,8 +1924,8 @@ packages:
|
|||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@movie-web/providers@2.0.5:
|
/@movie-web/providers@2.1.0:
|
||||||
resolution: {integrity: sha512-cefPTFXE7ctYeiibjk4HcNL3anRZ3lgYDAaJdzFzUrvkcSdxonP8GgGfDfPwmWWKip9dbP8Xv5aeauV/wrfaag==}
|
resolution: {integrity: sha512-L7Nn5n1+0HNXha0A6bymJSGVLhyC4qd5S2r5Xk5FeqxMlqKBqOlMpUmfHiZOssog70sxTAvRfFqmKkM4UXV8kg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
cheerio: 1.0.0-rc.12
|
cheerio: 1.0.0-rc.12
|
||||||
crypto-js: 4.2.0
|
crypto-js: 4.2.0
|
||||||
@ -1980,6 +1983,18 @@ packages:
|
|||||||
tslib: 2.6.2
|
tslib: 2.6.2
|
||||||
dev: true
|
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):
|
/@react-spring/animated@9.7.3(react@18.2.0):
|
||||||
resolution: {integrity: sha512-5CWeNJt9pNgyvuSzQH+uy2pvTg8Y4/OisoscZIR8/ZNLIOI+CatFBhGZpDGTF/OzdNFsAoGk3wiUYTwoJ0YIvw==}
|
resolution: {integrity: sha512-5CWeNJt9pNgyvuSzQH+uy2pvTg8Y4/OisoscZIR8/ZNLIOI+CatFBhGZpDGTF/OzdNFsAoGk3wiUYTwoJ0YIvw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -5166,6 +5181,12 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
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:
|
/nanoid@5.0.4:
|
||||||
resolution: {integrity: sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==}
|
resolution: {integrity: sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==}
|
||||||
engines: {node: ^18 || >=20}
|
engines: {node: ^18 || >=20}
|
||||||
|
@ -97,7 +97,8 @@
|
|||||||
"login": "Login",
|
"login": "Login",
|
||||||
"pagetitle": "{{title}} - movie-web",
|
"pagetitle": "{{title}} - movie-web",
|
||||||
"register": "Register",
|
"register": "Register",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"onboarding": "Setup"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
@ -231,7 +232,7 @@
|
|||||||
"downloadSubtitle": "Download current subtitle",
|
"downloadSubtitle": "Download current subtitle",
|
||||||
"downloadPlaylist": "Download playlist",
|
"downloadPlaylist": "Download playlist",
|
||||||
"downloadVideo": "Download video",
|
"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": {
|
"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>.",
|
"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",
|
"shortTitle": "Download / Android",
|
||||||
@ -276,6 +277,17 @@
|
|||||||
"homeButton": "Back to home",
|
"homeButton": "Back to home",
|
||||||
"text": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL.",
|
"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."
|
"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": {
|
"nextEpisode": {
|
||||||
@ -392,6 +404,28 @@
|
|||||||
"colorLabel": "Color"
|
"colorLabel": "Color"
|
||||||
},
|
},
|
||||||
"connections": {
|
"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": {
|
"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>",
|
"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",
|
"label": "Custom server",
|
||||||
@ -407,10 +441,13 @@
|
|||||||
"urlPlaceholder": "https://"
|
"urlPlaceholder": "https://"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"locale": {
|
"preferences": {
|
||||||
"language": "Application language",
|
"language": "Application language",
|
||||||
"languageDescription": "Language applied to the entire application.",
|
"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",
|
"reset": "Reset",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@ -429,5 +466,64 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"unsaved": "You have unsaved changes"
|
"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 { ofetch } from "ofetch";
|
||||||
|
|
||||||
import { getApiToken, setApiToken } from "@/backend/helpers/providerApi";
|
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 P<T> = Parameters<typeof ofetch<T, any>>;
|
||||||
type R<T> = ReturnType<typeof ofetch<T, any>>;
|
type R<T> = ReturnType<typeof ofetch<T, any>>;
|
||||||
|
@ -1,12 +1,6 @@
|
|||||||
import {
|
import { Fetcher, makeSimpleProxyFetcher } from "@movie-web/providers";
|
||||||
Fetcher,
|
|
||||||
ProviderControls,
|
|
||||||
makeProviders,
|
|
||||||
makeSimpleProxyFetcher,
|
|
||||||
makeStandardFetcher,
|
|
||||||
targets,
|
|
||||||
} from "@movie-web/providers";
|
|
||||||
|
|
||||||
|
import { sendExtensionRequest } from "@/backend/extension/messaging";
|
||||||
import { getApiToken, setApiToken } from "@/backend/helpers/providerApi";
|
import { getApiToken, setApiToken } from "@/backend/helpers/providerApi";
|
||||||
import { getProviderApiUrls, getProxyUrls } from "@/utils/proxyUrls";
|
import { getProviderApiUrls, getProxyUrls } from "@/utils/proxyUrls";
|
||||||
|
|
||||||
@ -48,7 +42,7 @@ async function fetchButWithApiTokens(
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeLoadBalancedSimpleProxyFetcher() {
|
export function makeLoadBalancedSimpleProxyFetcher() {
|
||||||
const fetcher: Fetcher = async (a, b) => {
|
const fetcher: Fetcher = async (a, b) => {
|
||||||
const currentFetcher = makeSimpleProxyFetcher(
|
const currentFetcher = makeSimpleProxyFetcher(
|
||||||
getLoadbalancedProxyUrl(),
|
getLoadbalancedProxyUrl(),
|
||||||
@ -59,8 +53,32 @@ function makeLoadBalancedSimpleProxyFetcher() {
|
|||||||
return fetcher;
|
return fetcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const providers = makeProviders({
|
function makeFinalHeaders(
|
||||||
fetcher: makeStandardFetcher(fetch),
|
readHeaders: string[],
|
||||||
proxiedFetcher: makeLoadBalancedSimpleProxyFetcher(),
|
headers: Record<string, string>,
|
||||||
target: targets.BROWSER,
|
): Headers {
|
||||||
}) as any as ProviderControls;
|
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";
|
import classNames from "classnames";
|
||||||
|
|
||||||
export function Toggle(props: { onClick: () => void; enabled?: boolean }) {
|
export function Toggle(props: { onClick?: () => void; enabled?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
interface ThinContainerProps {
|
interface ThinContainerProps {
|
||||||
@ -16,3 +17,16 @@ export function ThinContainer(props: ThinContainerProps) {
|
|||||||
</div>
|
</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 }) {
|
export function ModalCard(props: { children?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-[30rem] m-4">
|
<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}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -134,7 +134,7 @@ export function CaptionsView({ id }: { id: string }) {
|
|||||||
[selectCaptionById, setCurrentlyDownloading],
|
[selectCaptionById, setCurrentlyDownloading],
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = subtitleList.map((v, i) => {
|
const content = subtitleList.map((v) => {
|
||||||
return (
|
return (
|
||||||
<CaptionOption
|
<CaptionOption
|
||||||
// key must use index to prevent url collisions
|
// key must use index to prevent url collisions
|
||||||
|
@ -27,6 +27,7 @@ function StyleTrans(props: { k: string }) {
|
|||||||
i18nKey={props.k}
|
i18nKey={props.k}
|
||||||
components={{
|
components={{
|
||||||
bold: <Menu.Highlight />,
|
bold: <Menu.Highlight />,
|
||||||
|
br: <br />,
|
||||||
ios_share: (
|
ios_share: (
|
||||||
<Icon icon={Icons.IOS_SHARE} className="inline-block text-xl -mb-1" />
|
<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 }) {
|
function AndroidExplanationView({ id }: { id: string }) {
|
||||||
const router = useOverlayRouter(id);
|
const router = useOverlayRouter(id);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -202,11 +185,6 @@ export function DownloadRoutes({ id }: { id: string }) {
|
|||||||
<DownloadView id={id} />
|
<DownloadView id={id} />
|
||||||
</Menu.CardWithScrollable>
|
</Menu.CardWithScrollable>
|
||||||
</OverlayPage>
|
</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}>
|
<OverlayPage id={id} path="/download/ios" width={343} height={440}>
|
||||||
<Menu.CardWithScrollable>
|
<Menu.CardWithScrollable>
|
||||||
<IOSExplanationView id={id} />
|
<IOSExplanationView id={id} />
|
||||||
|
@ -147,7 +147,7 @@ export function SourceSelectionView({
|
|||||||
<Menu.BackLink onClick={() => router.navigate("/")}>
|
<Menu.BackLink onClick={() => router.navigate("/")}>
|
||||||
{t("player.menus.sources.title")}
|
{t("player.menus.sources.title")}
|
||||||
</Menu.BackLink>
|
</Menu.BackLink>
|
||||||
<Menu.Section>
|
<Menu.Section className="pb-4">
|
||||||
{sources.map((v) => (
|
{sources.map((v) => (
|
||||||
<SelectableLink
|
<SelectableLink
|
||||||
key={v.id}
|
key={v.id}
|
||||||
|
@ -41,6 +41,7 @@ function qualityToHlsLevel(quality: SourceQuality): number | null {
|
|||||||
);
|
);
|
||||||
return found ? +found[0] : null;
|
return found ? +found[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hlsLevelsToQualities(levels: Level[]): SourceQuality[] {
|
function hlsLevelsToQualities(levels: Level[]): SourceQuality[] {
|
||||||
return levels
|
return levels
|
||||||
.map((v) => hlsLevelToQuality(v))
|
.map((v) => hlsLevelToQuality(v))
|
||||||
|
@ -5,6 +5,8 @@ import {
|
|||||||
} from "@movie-web/providers";
|
} from "@movie-web/providers";
|
||||||
import { useAsyncFn } from "react-use";
|
import { useAsyncFn } from "react-use";
|
||||||
|
|
||||||
|
import { isExtensionActiveCached } from "@/backend/extension/messaging";
|
||||||
|
import { prepareStream } from "@/backend/extension/streams";
|
||||||
import {
|
import {
|
||||||
connectServerSideEvents,
|
connectServerSideEvents,
|
||||||
makeProviderUrl,
|
makeProviderUrl,
|
||||||
@ -13,12 +15,13 @@ import {
|
|||||||
scrapeSourceOutputToProviderMetric,
|
scrapeSourceOutputToProviderMetric,
|
||||||
useReportProviders,
|
useReportProviders,
|
||||||
} from "@/backend/helpers/report";
|
} from "@/backend/helpers/report";
|
||||||
|
import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers";
|
||||||
|
import { getProviders } from "@/backend/providers/providers";
|
||||||
import { convertProviderCaption } from "@/components/player/utils/captions";
|
import { convertProviderCaption } from "@/components/player/utils/captions";
|
||||||
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
|
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
|
||||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
import { metaToScrapeMedia } from "@/stores/player/slices/source";
|
import { metaToScrapeMedia } from "@/stores/player/slices/source";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
import { getLoadbalancedProviderApiUrl, providers } from "@/utils/providers";
|
|
||||||
|
|
||||||
export function useEmbedScraping(
|
export function useEmbedScraping(
|
||||||
routerId: string,
|
routerId: string,
|
||||||
@ -47,7 +50,7 @@ export function useEmbedScraping(
|
|||||||
);
|
);
|
||||||
result = await conn.promise();
|
result = await conn.promise();
|
||||||
} else {
|
} else {
|
||||||
result = await providers.runEmbedScraper({
|
result = await getProviders().runEmbedScraper({
|
||||||
id: embedId,
|
id: embedId,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
@ -70,6 +73,7 @@ export function useEmbedScraping(
|
|||||||
report([
|
report([
|
||||||
scrapeSourceOutputToProviderMetric(meta, sourceId, null, "success", null),
|
scrapeSourceOutputToProviderMetric(meta, sourceId, null, "success", null),
|
||||||
]);
|
]);
|
||||||
|
if (isExtensionActiveCached()) await prepareStream(result.stream[0]);
|
||||||
setSourceId(sourceId);
|
setSourceId(sourceId);
|
||||||
setCaption(null);
|
setCaption(null);
|
||||||
setSource(
|
setSource(
|
||||||
@ -111,7 +115,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
|||||||
);
|
);
|
||||||
result = await conn.promise();
|
result = await conn.promise();
|
||||||
} else {
|
} else {
|
||||||
result = await providers.runSourceScraper({
|
result = await getProviders().runSourceScraper({
|
||||||
id: sourceId,
|
id: sourceId,
|
||||||
media: scrapeMedia,
|
media: scrapeMedia,
|
||||||
});
|
});
|
||||||
@ -130,6 +134,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (result.stream) {
|
if (result.stream) {
|
||||||
|
if (isExtensionActiveCached()) await prepareStream(result.stream[0]);
|
||||||
setCaption(null);
|
setCaption(null);
|
||||||
setSource(
|
setSource(
|
||||||
convertRunoutputToSource({ stream: result.stream[0] }),
|
convertRunoutputToSource({ stream: result.stream[0] }),
|
||||||
@ -155,7 +160,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
|||||||
);
|
);
|
||||||
embedResult = await conn.promise();
|
embedResult = await conn.promise();
|
||||||
} else {
|
} else {
|
||||||
embedResult = await providers.runEmbedScraper({
|
embedResult = await getProviders().runEmbedScraper({
|
||||||
id: result.embeds[0].embedId,
|
id: result.embeds[0].embedId,
|
||||||
url: result.embeds[0].url,
|
url: result.embeds[0].url,
|
||||||
});
|
});
|
||||||
@ -186,6 +191,7 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
|||||||
]);
|
]);
|
||||||
setSourceId(sourceId);
|
setSourceId(sourceId);
|
||||||
setCaption(null);
|
setCaption(null);
|
||||||
|
if (isExtensionActiveCached()) await prepareStream(embedResult.stream[0]);
|
||||||
setSource(
|
setSource(
|
||||||
convertRunoutputToSource({ stream: embedResult.stream[0] }),
|
convertRunoutputToSource({ stream: embedResult.stream[0] }),
|
||||||
convertProviderCaption(embedResult.stream[0].captions),
|
convertProviderCaption(embedResult.stream[0].captions),
|
||||||
|
@ -2,7 +2,10 @@ import classNames from "classnames";
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { Transition } from "@/components/utils/Transition";
|
||||||
|
|
||||||
export interface ScrapeItemProps {
|
export interface ScrapeItemProps {
|
||||||
@ -23,13 +26,14 @@ const statusTextMap: Partial<Record<ScrapeCardProps["status"], string>> = {
|
|||||||
pending: "player.scraping.items.pending",
|
pending: "player.scraping.items.pending",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusMap: Record<ScrapeCardProps["status"], StatusCircle["type"]> = {
|
const statusMap: Record<ScrapeCardProps["status"], StatusCircleProps["type"]> =
|
||||||
failure: "error",
|
{
|
||||||
notfound: "noresult",
|
failure: "error",
|
||||||
pending: "loading",
|
notfound: "noresult",
|
||||||
success: "success",
|
pending: "loading",
|
||||||
waiting: "waiting",
|
success: "success",
|
||||||
};
|
waiting: "waiting",
|
||||||
|
};
|
||||||
|
|
||||||
export function ScrapeItem(props: ScrapeItemProps) {
|
export function ScrapeItem(props: ScrapeItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -4,23 +4,24 @@ import classNames from "classnames";
|
|||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { Transition } from "@/components/utils/Transition";
|
import { Transition } from "@/components/utils/Transition";
|
||||||
|
|
||||||
export interface StatusCircle {
|
export interface StatusCircleProps {
|
||||||
type: "loading" | "success" | "error" | "noresult" | "waiting";
|
type: "loading" | "success" | "error" | "noresult" | "waiting";
|
||||||
percentage?: number;
|
percentage?: number;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusCircleLoading extends StatusCircle {
|
export interface StatusCircleLoading extends StatusCircleProps {
|
||||||
type: "loading";
|
type: "loading";
|
||||||
percentage: number;
|
percentage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusIsLoading(
|
function statusIsLoading(
|
||||||
props: StatusCircle | StatusCircleLoading,
|
props: StatusCircleProps | StatusCircleLoading,
|
||||||
): props is StatusCircleLoading {
|
): props is StatusCircleLoading {
|
||||||
return props.type === "loading";
|
return props.type === "loading";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
|
export function StatusCircle(props: StatusCircleProps | StatusCircleLoading) {
|
||||||
const [spring] = useSpring(
|
const [spring] = useSpring(
|
||||||
() => ({
|
() => ({
|
||||||
percentage: statusIsLoading(props) ? props.percentage : 0,
|
percentage: statusIsLoading(props) ? props.percentage : 0,
|
||||||
@ -30,18 +31,21 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames({
|
className={classNames(
|
||||||
"p-0.5 border-current border-[3px] rounded-full h-6 w-6 relative transition-colors":
|
{
|
||||||
true,
|
"p-0.5 border-current border-[3px] rounded-full h-6 w-6 relative transition-colors":
|
||||||
"text-video-scraping-loading": props.type === "loading",
|
true,
|
||||||
"text-video-scraping-noresult text-opacity-50":
|
"text-video-scraping-loading": props.type === "loading",
|
||||||
props.type === "waiting",
|
"text-video-scraping-noresult text-opacity-50":
|
||||||
"text-video-scraping-error bg-video-scraping-error":
|
props.type === "waiting",
|
||||||
props.type === "error",
|
"text-video-scraping-error bg-video-scraping-error":
|
||||||
"text-green-500 bg-green-500": props.type === "success",
|
props.type === "error",
|
||||||
"text-video-scraping-noresult bg-video-scraping-noresult":
|
"text-green-500 bg-green-500": props.type === "success",
|
||||||
props.type === "noresult",
|
"text-video-scraping-noresult bg-video-scraping-noresult":
|
||||||
})}
|
props.type === "noresult",
|
||||||
|
},
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Transition animation="fade" show={statusIsLoading(props)}>
|
<Transition animation="fade" show={statusIsLoading(props)}>
|
||||||
<svg
|
<svg
|
||||||
@ -65,13 +69,13 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
|
|||||||
</Transition>
|
</Transition>
|
||||||
<Transition animation="fade" show={props.type === "error"}>
|
<Transition animation="fade" show={props.type === "error"}>
|
||||||
<Icon
|
<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}
|
icon={Icons.X}
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Transition animation="fade" show={props.type === "success"}>
|
<Transition animation="fade" show={props.type === "success"}>
|
||||||
<Icon
|
<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}
|
icon={Icons.CHECKMARK}
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
@ -5,6 +5,7 @@ import { playerStatus } from "@/stores/player/slices/source";
|
|||||||
import { ThumbnailImage } from "@/stores/player/slices/thumbnails";
|
import { ThumbnailImage } from "@/stores/player/slices/thumbnails";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
import { LoadableSource, selectQuality } from "@/stores/player/utils/qualities";
|
import { LoadableSource, selectQuality } from "@/stores/player/utils/qualities";
|
||||||
|
import { usePreferencesStore } from "@/stores/preferences";
|
||||||
import { processCdnLink } from "@/utils/cdn";
|
import { processCdnLink } from "@/utils/cdn";
|
||||||
import { isSafari } from "@/utils/detectFeatures";
|
import { isSafari } from "@/utils/detectFeatures";
|
||||||
|
|
||||||
@ -128,6 +129,7 @@ export function ThumbnailScraper() {
|
|||||||
const resetImages = usePlayerStore((s) => s.thumbnails.resetImages);
|
const resetImages = usePlayerStore((s) => s.thumbnails.resetImages);
|
||||||
const meta = usePlayerStore((s) => s.meta);
|
const meta = usePlayerStore((s) => s.meta);
|
||||||
const source = usePlayerStore((s) => s.source);
|
const source = usePlayerStore((s) => s.source);
|
||||||
|
const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails);
|
||||||
const workerRef = useRef<ThumnbnailWorker | null>(null);
|
const workerRef = useRef<ThumnbnailWorker | null>(null);
|
||||||
|
|
||||||
// object references dont always trigger changes, so we serialize it to detect *any* change
|
// 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
|
// start worker with the stream
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startRef.current();
|
if (enableThumbnails) startRef.current();
|
||||||
}, [sourceSeralized]);
|
}, [sourceSeralized, enableThumbnails]);
|
||||||
|
|
||||||
// destroy worker on unmount
|
// destroy worker on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -183,8 +185,8 @@ export function ThumbnailScraper() {
|
|||||||
workerRef.current.destroy();
|
workerRef.current.destroy();
|
||||||
workerRef.current = null;
|
workerRef.current = null;
|
||||||
}
|
}
|
||||||
startRef.current();
|
if (enableThumbnails) startRef.current();
|
||||||
}, [serializedMeta, sourceSeralized, status]);
|
}, [serializedMeta, sourceSeralized, status, enableThumbnails]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ export function convertRunoutputToSource(out: {
|
|||||||
return {
|
return {
|
||||||
type: "hls",
|
type: "hls",
|
||||||
url: out.stream.playlist,
|
url: out.stream.playlist,
|
||||||
|
preferredHeaders: out.stream.preferredHeaders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (out.stream.type === "file") {
|
if (out.stream.type === "file") {
|
||||||
@ -49,6 +50,7 @@ export function convertRunoutputToSource(out: {
|
|||||||
return {
|
return {
|
||||||
type: "file",
|
type: "file",
|
||||||
qualities,
|
qualities,
|
||||||
|
preferredHeaders: out.stream.preferredHeaders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error("unrecognized type");
|
throw new Error("unrecognized type");
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { TextInputControl } from "./TextInputControl";
|
import { TextInputControl } from "./TextInputControl";
|
||||||
|
|
||||||
export function AuthInputBox(props: {
|
export function AuthInputBox(props: {
|
||||||
@ -8,9 +10,10 @@ export function AuthInputBox(props: {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange?: (data: string) => void;
|
onChange?: (data: string) => void;
|
||||||
passwordToggleable?: boolean;
|
passwordToggleable?: boolean;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className={classNames("space-y-3", props.className)}>
|
||||||
{props.label ? (
|
{props.label ? (
|
||||||
<p className="font-bold text-white">{props.label}</p>
|
<p className="font-bold text-white">{props.label}</p>
|
||||||
) : null}
|
) : 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={{
|
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)`,
|
backgroundPosition: `var(--bg-x) var(--bg-y)`,
|
||||||
backgroundRepeat: "no-repeat",
|
backgroundRepeat: "no-repeat",
|
||||||
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
|
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
|
||||||
@ -85,7 +85,7 @@ function Light(props: FlareProps) {
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-10"
|
className="absolute inset-0 opacity-10"
|
||||||
style={{
|
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)`,
|
backgroundPosition: `var(--bg-x) var(--bg-y)`,
|
||||||
backgroundRepeat: "no-repeat",
|
backgroundRepeat: "no-repeat",
|
||||||
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
|
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
|
||||||
|
@ -5,12 +5,15 @@ import {
|
|||||||
} from "@movie-web/providers";
|
} from "@movie-web/providers";
|
||||||
import { RefObject, useCallback, useEffect, useRef, useState } from "react";
|
import { RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { isExtensionActiveCached } from "@/backend/extension/messaging";
|
||||||
|
import { prepareStream } from "@/backend/extension/streams";
|
||||||
import {
|
import {
|
||||||
connectServerSideEvents,
|
connectServerSideEvents,
|
||||||
getCachedMetadata,
|
getCachedMetadata,
|
||||||
makeProviderUrl,
|
makeProviderUrl,
|
||||||
} from "@/backend/helpers/providerApi";
|
} 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 {
|
export interface ScrapingItems {
|
||||||
id: string;
|
id: string;
|
||||||
@ -168,12 +171,14 @@ export function useScrape() {
|
|||||||
conn.on("update", updateEvent);
|
conn.on("update", updateEvent);
|
||||||
conn.on("discoverEmbeds", discoverEmbedsEvent);
|
conn.on("discoverEmbeds", discoverEmbedsEvent);
|
||||||
const sseOutput = await conn.promise();
|
const sseOutput = await conn.promise();
|
||||||
|
if (sseOutput && isExtensionActiveCached())
|
||||||
|
await prepareStream(sseOutput.stream);
|
||||||
|
|
||||||
return getResult(sseOutput === "" ? null : sseOutput);
|
return getResult(sseOutput === "" ? null : sseOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!providers) return null;
|
|
||||||
startScrape();
|
startScrape();
|
||||||
|
const providers = getProviders();
|
||||||
const output = await providers.runAll({
|
const output = await providers.runAll({
|
||||||
media,
|
media,
|
||||||
events: {
|
events: {
|
||||||
@ -183,6 +188,8 @@ export function useScrape() {
|
|||||||
discoverEmbeds: discoverEmbedsEvent,
|
discoverEmbeds: discoverEmbedsEvent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (output && isExtensionActiveCached())
|
||||||
|
await prepareStream(output.stream);
|
||||||
return getResult(output);
|
return getResult(output);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
@ -49,6 +49,7 @@ export function useSettingsState(
|
|||||||
icon: string;
|
icon: string;
|
||||||
}
|
}
|
||||||
| undefined,
|
| undefined,
|
||||||
|
enableThumbnails: boolean,
|
||||||
) {
|
) {
|
||||||
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
|
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
|
||||||
useDerived(proxyUrls);
|
useDerived(proxyUrls);
|
||||||
@ -71,6 +72,12 @@ export function useSettingsState(
|
|||||||
] = useDerived(deviceName);
|
] = useDerived(deviceName);
|
||||||
const [profileState, setProfileState, resetProfile, profileChanged] =
|
const [profileState, setProfileState, resetProfile, profileChanged] =
|
||||||
useDerived(profile);
|
useDerived(profile);
|
||||||
|
const [
|
||||||
|
enableThumbnailsState,
|
||||||
|
setEnableThumbnailsState,
|
||||||
|
resetEnableThumbnails,
|
||||||
|
enableThumbnailsChanged,
|
||||||
|
] = useDerived(enableThumbnails);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
resetTheme();
|
resetTheme();
|
||||||
@ -80,6 +87,7 @@ export function useSettingsState(
|
|||||||
resetBackendUrl();
|
resetBackendUrl();
|
||||||
resetDeviceName();
|
resetDeviceName();
|
||||||
resetProfile();
|
resetProfile();
|
||||||
|
resetEnableThumbnails();
|
||||||
}
|
}
|
||||||
|
|
||||||
const changed =
|
const changed =
|
||||||
@ -89,7 +97,8 @@ export function useSettingsState(
|
|||||||
deviceNameChanged ||
|
deviceNameChanged ||
|
||||||
backendUrlChanged ||
|
backendUrlChanged ||
|
||||||
proxyUrlsChanged ||
|
proxyUrlsChanged ||
|
||||||
profileChanged;
|
profileChanged ||
|
||||||
|
enableThumbnailsChanged;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reset,
|
reset,
|
||||||
@ -129,5 +138,10 @@ export function useSettingsState(
|
|||||||
set: setProfileState,
|
set: setProfileState,
|
||||||
changed: profileChanged,
|
changed: profileChanged,
|
||||||
},
|
},
|
||||||
|
enableThumbnails: {
|
||||||
|
state: enableThumbnailsState,
|
||||||
|
set: setEnableThumbnailsState,
|
||||||
|
changed: enableThumbnailsChanged,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import { RunOutput } from "@movie-web/providers";
|
import { RunOutput } from "@movie-web/providers";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 { usePlayer } from "@/components/player/hooks/usePlayer";
|
||||||
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
|
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 { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
|
||||||
import { useLastNonPlayerLink } from "@/stores/history";
|
import { useLastNonPlayerLink } from "@/stores/history";
|
||||||
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
|
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
|
||||||
|
import { needsOnboarding } from "@/utils/onboarding";
|
||||||
import { parseTimestamp } from "@/utils/timestamp";
|
import { parseTimestamp } from "@/utils/timestamp";
|
||||||
|
|
||||||
export function PlayerView() {
|
export function RealPlayerView() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const params = useParams<{
|
const params = useParams<{
|
||||||
media: string;
|
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;
|
export default PlayerView;
|
||||||
|
@ -31,11 +31,12 @@ import { ThemePart } from "@/pages/parts/settings/ThemePart";
|
|||||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||||
import { AccountWithToken, useAuthStore } from "@/stores/auth";
|
import { AccountWithToken, useAuthStore } from "@/stores/auth";
|
||||||
import { useLanguageStore } from "@/stores/language";
|
import { useLanguageStore } from "@/stores/language";
|
||||||
|
import { usePreferencesStore } from "@/stores/preferences";
|
||||||
import { useSubtitleStore } from "@/stores/subtitles";
|
import { useSubtitleStore } from "@/stores/subtitles";
|
||||||
import { useThemeStore } from "@/stores/theme";
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
|
||||||
import { SubPageLayout } from "./layouts/SubPageLayout";
|
import { SubPageLayout } from "./layouts/SubPageLayout";
|
||||||
import { LocalePart } from "./parts/settings/LocalePart";
|
import { PreferencesPart } from "./parts/settings/PreferencesPart";
|
||||||
|
|
||||||
function SettingsLayout(props: { children: React.ReactNode }) {
|
function SettingsLayout(props: { children: React.ReactNode }) {
|
||||||
const { isMobile } = useIsMobile();
|
const { isMobile } = useIsMobile();
|
||||||
@ -115,6 +116,9 @@ export function SettingsPage() {
|
|||||||
const backendUrlSetting = useAuthStore((s) => s.backendUrl);
|
const backendUrlSetting = useAuthStore((s) => s.backendUrl);
|
||||||
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
|
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 account = useAuthStore((s) => s.account);
|
||||||
const updateProfile = useAuthStore((s) => s.setAccountProfile);
|
const updateProfile = useAuthStore((s) => s.setAccountProfile);
|
||||||
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
|
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
|
||||||
@ -136,6 +140,7 @@ export function SettingsPage() {
|
|||||||
proxySet,
|
proxySet,
|
||||||
backendUrlSetting,
|
backendUrlSetting,
|
||||||
account?.profile,
|
account?.profile,
|
||||||
|
enableThumbnails,
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveChanges = useCallback(async () => {
|
const saveChanges = useCallback(async () => {
|
||||||
@ -168,6 +173,7 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEnableThumbnails(state.enableThumbnails.state);
|
||||||
setAppLanguage(state.appLanguage.state);
|
setAppLanguage(state.appLanguage.state);
|
||||||
setTheme(state.theme.state);
|
setTheme(state.theme.state);
|
||||||
setSubStyling(state.subtitleStyling.state);
|
setSubStyling(state.subtitleStyling.state);
|
||||||
@ -186,6 +192,7 @@ export function SettingsPage() {
|
|||||||
state,
|
state,
|
||||||
account,
|
account,
|
||||||
backendUrl,
|
backendUrl,
|
||||||
|
setEnableThumbnails,
|
||||||
setAppLanguage,
|
setAppLanguage,
|
||||||
setTheme,
|
setTheme,
|
||||||
setSubStyling,
|
setSubStyling,
|
||||||
@ -225,10 +232,12 @@ export function SettingsPage() {
|
|||||||
<RegisterCalloutPart />
|
<RegisterCalloutPart />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-locale" className="mt-48">
|
<div id="settings-preferences" className="mt-48">
|
||||||
<LocalePart
|
<PreferencesPart
|
||||||
language={state.appLanguage.state}
|
language={state.appLanguage.state}
|
||||||
setLanguage={state.appLanguage.set}
|
setLanguage={state.appLanguage.set}
|
||||||
|
enableThumbnails={state.enableThumbnails.state}
|
||||||
|
setEnableThumbnails={state.enableThumbnails.set}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-appearance" className="mt-48">
|
<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 { useAsync } from "react-use";
|
||||||
import type { AsyncReturnType } from "type-fest";
|
import type { AsyncReturnType } from "type-fest";
|
||||||
|
|
||||||
|
import { isAllowedExtensionVersion } from "@/backend/extension/compatibility";
|
||||||
|
import { extensionInfo, sendPage } from "@/backend/extension/messaging";
|
||||||
import {
|
import {
|
||||||
fetchMetadata,
|
fetchMetadata,
|
||||||
setCachedMetadata,
|
setCachedMetadata,
|
||||||
@ -10,6 +12,8 @@ import {
|
|||||||
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
||||||
import { decodeTMDBId } from "@/backend/metadata/tmdb";
|
import { decodeTMDBId } from "@/backend/metadata/tmdb";
|
||||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
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 { Button } from "@/components/buttons/Button";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { IconPill } from "@/components/layout/IconPill";
|
import { IconPill } from "@/components/layout/IconPill";
|
||||||
@ -18,7 +22,6 @@ import { Paragraph } from "@/components/text/Paragraph";
|
|||||||
import { Title } from "@/components/text/Title";
|
import { Title } from "@/components/text/Title";
|
||||||
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
|
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
import { getLoadbalancedProviderApiUrl, providers } from "@/utils/providers";
|
|
||||||
|
|
||||||
export interface MetaPartProps {
|
export interface MetaPartProps {
|
||||||
onGetMeta?: (meta: DetailedMeta, episodeId?: string) => void;
|
onGetMeta?: (meta: DetailedMeta, episodeId?: string) => void;
|
||||||
@ -41,8 +44,17 @@ export function MetaPart(props: MetaPartProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { error, value, loading } = useAsync(async () => {
|
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();
|
const providerApiUrl = getLoadbalancedProviderApiUrl();
|
||||||
if (providerApiUrl) {
|
if (providerApiUrl && !isValidExtension) {
|
||||||
try {
|
try {
|
||||||
await fetchMetadata(providerApiUrl);
|
await fetchMetadata(providerApiUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -50,11 +62,12 @@ export function MetaPart(props: MetaPartProps) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setCachedMetadata([
|
setCachedMetadata([
|
||||||
...providers.listSources(),
|
...getProviders().listSources(),
|
||||||
...providers.listEmbeds(),
|
...getProviders().listEmbeds(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get media meta data
|
||||||
let data: ReturnType<typeof decodeTMDBId> = null;
|
let data: ReturnType<typeof decodeTMDBId> = null;
|
||||||
try {
|
try {
|
||||||
if (!params.media) throw new Error("no media params");
|
if (!params.media) throw new Error("no media params");
|
||||||
@ -98,16 +111,42 @@ export function MetaPart(props: MetaPartProps) {
|
|||||||
props.onGetMeta?.(meta, epId);
|
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") {
|
if (error && error.message === "dmca") {
|
||||||
return (
|
return (
|
||||||
<ErrorLayout>
|
<ErrorLayout>
|
||||||
<ErrorContainer>
|
<ErrorContainer>
|
||||||
<IconPill icon={Icons.DRAGON}>Removed</IconPill>
|
<IconPill icon={Icons.DRAGON}>
|
||||||
<Title>Media has been removed</Title>
|
{t("player.metadata.dmca.badge")}
|
||||||
<Paragraph>
|
</IconPill>
|
||||||
This media is no longer available due to a takedown notice or
|
<Title>{t("player.metadata.dmca.title")}</Title>
|
||||||
copyright claim.
|
<Paragraph>{t("player.metadata.dmca.text")}</Paragraph>
|
||||||
</Paragraph>
|
|
||||||
<Button
|
<Button
|
||||||
href="/"
|
href="/"
|
||||||
theme="purple"
|
theme="purple"
|
||||||
|
@ -9,6 +9,7 @@ import { MwLink } from "@/components/text/Link";
|
|||||||
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
||||||
import { Divider } from "@/components/utils/Divider";
|
import { Divider } from "@/components/utils/Divider";
|
||||||
import { Heading1 } from "@/components/utils/Text";
|
import { Heading1 } from "@/components/utils/Text";
|
||||||
|
import { SetupPart } from "@/pages/parts/settings/SetupPart";
|
||||||
|
|
||||||
interface ProxyEditProps {
|
interface ProxyEditProps {
|
||||||
proxyUrls: string[] | null;
|
proxyUrls: string[] | null;
|
||||||
@ -156,6 +157,7 @@ export function ConnectionsPart(props: BackendEditProps & ProxyEditProps) {
|
|||||||
<div>
|
<div>
|
||||||
<Heading1 border>{t("settings.connections.title")}</Heading1>
|
<Heading1 border>{t("settings.connections.title")}</Heading1>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<SetupPart />
|
||||||
<ProxyEdit
|
<ProxyEdit
|
||||||
proxyUrls={props.proxyUrls}
|
proxyUrls={props.proxyUrls}
|
||||||
setProxyUrls={props.setProxyUrls}
|
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,
|
icon: Icons.USER,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
textKey: "settings.locale.title",
|
textKey: "settings.preferences.title",
|
||||||
id: "settings-locale",
|
id: "settings-preferences",
|
||||||
icon: Icons.BOOKMARK,
|
icon: Icons.SETTINGS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
textKey: "settings.appearance.title",
|
textKey: "settings.appearance.title",
|
||||||
|
@ -19,6 +19,9 @@ import { DmcaPage, shouldHaveDmcaPage } from "@/pages/Dmca";
|
|||||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||||
import { HomePage } from "@/pages/HomePage";
|
import { HomePage } from "@/pages/HomePage";
|
||||||
import { LoginPage } from "@/pages/Login";
|
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 { RegisterPage } from "@/pages/Register";
|
||||||
import { Layout } from "@/setup/Layout";
|
import { Layout } from "@/setup/Layout";
|
||||||
import { useHistoryListener } from "@/stores/history";
|
import { useHistoryListener } from "@/stores/history";
|
||||||
@ -119,6 +122,12 @@ function App() {
|
|||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
|
<Route
|
||||||
|
path="/onboarding/extension"
|
||||||
|
element={<OnboardingExtensionPage />}
|
||||||
|
/>
|
||||||
|
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
|
||||||
|
|
||||||
{shouldHaveDmcaPage() ? (
|
{shouldHaveDmcaPage() ? (
|
||||||
<Route path="/dmca" element={<DmcaPage />} />
|
<Route path="/dmca" element={<DmcaPage />} />
|
||||||
|
@ -19,6 +19,7 @@ interface Config {
|
|||||||
DISALLOWED_IDS: string;
|
DISALLOWED_IDS: string;
|
||||||
TURNSTILE_KEY: string;
|
TURNSTILE_KEY: string;
|
||||||
CDN_REPLACEMENTS: string;
|
CDN_REPLACEMENTS: string;
|
||||||
|
HAS_ONBOARDING: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeConfig {
|
export interface RuntimeConfig {
|
||||||
@ -34,6 +35,7 @@ export interface RuntimeConfig {
|
|||||||
DISALLOWED_IDS: string[];
|
DISALLOWED_IDS: string[];
|
||||||
TURNSTILE_KEY: string | null;
|
TURNSTILE_KEY: string | null;
|
||||||
CDN_REPLACEMENTS: Array<string[]>;
|
CDN_REPLACEMENTS: Array<string[]>;
|
||||||
|
HAS_ONBOARDING: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const env: Record<keyof Config, undefined | string> = {
|
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,
|
DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS,
|
||||||
TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY,
|
TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY,
|
||||||
CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS,
|
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)
|
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
|
||||||
@ -82,6 +85,7 @@ export function conf(): RuntimeConfig {
|
|||||||
.split(",")
|
.split(",")
|
||||||
.map((v) => v.trim()),
|
.map((v) => v.trim()),
|
||||||
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
||||||
|
HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true",
|
||||||
TURNSTILE_KEY: turnstileKey.length > 0 ? turnstileKey : null,
|
TURNSTILE_KEY: turnstileKey.length > 0 ? turnstileKey : null,
|
||||||
DISALLOWED_IDS: getKey("DISALLOWED_IDS", "")
|
DISALLOWED_IDS: getKey("DISALLOWED_IDS", "")
|
||||||
.split(",")
|
.split(",")
|
||||||
|
@ -46,7 +46,8 @@ export function useLastNonPlayerLink() {
|
|||||||
(v) =>
|
(v) =>
|
||||||
!v.path.startsWith("/media") && // cannot be a player link
|
!v.path.startsWith("/media") && // cannot be a player link
|
||||||
location.pathname !== v.path && // cannot be current 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 ?? "/";
|
return route?.path ?? "/";
|
||||||
}, [routes, location]);
|
}, [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) {
|
setSourceId(id) {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
|
s.status = playerStatus.PLAYING;
|
||||||
s.sourceId = id;
|
s.sourceId = id;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -155,6 +156,8 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||||||
s.qualities = qualities as SourceQuality[];
|
s.qualities = qualities as SourceQuality[];
|
||||||
s.currentQuality = loadableStream.quality;
|
s.currentQuality = loadableStream.quality;
|
||||||
s.captionList = captions;
|
s.captionList = captions;
|
||||||
|
s.interface.error = undefined;
|
||||||
|
s.status = playerStatus.PLAYING;
|
||||||
});
|
});
|
||||||
const store = get();
|
const store = get();
|
||||||
store.redisplaySource(startAt);
|
store.redisplaySource(startAt);
|
||||||
@ -168,7 +171,10 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||||||
automaticQuality: qualityPreferences.quality.automaticQuality,
|
automaticQuality: qualityPreferences.quality.automaticQuality,
|
||||||
lastChosenQuality: quality,
|
lastChosenQuality: quality,
|
||||||
});
|
});
|
||||||
|
set((s) => {
|
||||||
|
s.interface.error = undefined;
|
||||||
|
s.status = playerStatus.PLAYING;
|
||||||
|
});
|
||||||
store.display?.load({
|
store.display?.load({
|
||||||
source: loadableStream.stream,
|
source: loadableStream.stream,
|
||||||
startAt,
|
startAt,
|
||||||
@ -184,6 +190,8 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||||||
if (!selectedQuality) return;
|
if (!selectedQuality) return;
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.currentQuality = quality;
|
s.currentQuality = quality;
|
||||||
|
s.status = playerStatus.PLAYING;
|
||||||
|
s.interface.error = undefined;
|
||||||
});
|
});
|
||||||
store.display?.load({
|
store.display?.load({
|
||||||
source: selectedQuality,
|
source: selectedQuality,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Qualities } from "@movie-web/providers";
|
import { Qualities, Stream } from "@movie-web/providers";
|
||||||
|
|
||||||
import { QualityStore } from "@/stores/quality";
|
import { QualityStore } from "@/stores/quality";
|
||||||
|
|
||||||
@ -14,16 +14,19 @@ export type SourceFileStream = {
|
|||||||
export type LoadableSource = {
|
export type LoadableSource = {
|
||||||
type: StreamType;
|
type: StreamType;
|
||||||
url: string;
|
url: string;
|
||||||
|
preferredHeaders?: Stream["preferredHeaders"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SourceSliceSource =
|
export type SourceSliceSource =
|
||||||
| {
|
| {
|
||||||
type: "file";
|
type: "file";
|
||||||
qualities: Partial<Record<SourceQuality, SourceFileStream>>;
|
qualities: Partial<Record<SourceQuality, SourceFileStream>>;
|
||||||
|
preferredHeaders?: Stream["preferredHeaders"];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "hls";
|
type: "hls";
|
||||||
url: string;
|
url: string;
|
||||||
|
preferredHeaders?: Stream["preferredHeaders"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const qualitySorting: Record<SourceQuality, number> = {
|
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
|
* @returns pretty format for language, null if it no info can be found for language
|
||||||
*/
|
*/
|
||||||
export function getPrettyLanguageNameFromLocale(locale: string): string | null {
|
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;
|
const lang = tag?.language?.Description?.[0] ?? null;
|
||||||
if (!lang) return 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;
|
||||||
|
}
|
@ -138,6 +138,11 @@ export const defaultTheme = {
|
|||||||
accentB: tokens.blue.c500,
|
accentB: tokens.blue.c500,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
modal: {
|
||||||
|
background: tokens.shade.c800,
|
||||||
|
},
|
||||||
|
|
||||||
// typography
|
// typography
|
||||||
type: {
|
type: {
|
||||||
logo: tokens.purple.c100,
|
logo: tokens.purple.c100,
|
||||||
@ -147,6 +152,7 @@ export const defaultTheme = {
|
|||||||
divider: tokens.ash.c500,
|
divider: tokens.ash.c500,
|
||||||
secondary: tokens.ash.c100,
|
secondary: tokens.ash.c100,
|
||||||
danger: tokens.semantic.red.c100,
|
danger: tokens.semantic.red.c100,
|
||||||
|
success: tokens.semantic.green.c100,
|
||||||
link: tokens.purple.c100,
|
link: tokens.purple.c100,
|
||||||
linkHover: tokens.purple.c50,
|
linkHover: tokens.purple.c50,
|
||||||
},
|
},
|
||||||
@ -228,10 +234,24 @@ export const defaultTheme = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Utilities
|
||||||
utils: {
|
utils: {
|
||||||
divider: tokens.ash.c300,
|
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
|
// Error page
|
||||||
errors: {
|
errors: {
|
||||||
card: tokens.shade.c800,
|
card: tokens.shade.c800,
|
||||||
|
@ -95,6 +95,10 @@ export default createTheme({
|
|||||||
accentB: tokens.blue.c500
|
accentB: tokens.blue.c500
|
||||||
},
|
},
|
||||||
|
|
||||||
|
modal: {
|
||||||
|
background: tokens.shade.c800,
|
||||||
|
},
|
||||||
|
|
||||||
type: {
|
type: {
|
||||||
logo: tokens.purple.c100,
|
logo: tokens.purple.c100,
|
||||||
text: tokens.shade.c50,
|
text: tokens.shade.c50,
|
||||||
|
@ -95,6 +95,10 @@ export default createTheme({
|
|||||||
accentB: tokens.blue.c500
|
accentB: tokens.blue.c500
|
||||||
},
|
},
|
||||||
|
|
||||||
|
modal: {
|
||||||
|
background: tokens.shade.c800,
|
||||||
|
},
|
||||||
|
|
||||||
type: {
|
type: {
|
||||||
logo: tokens.purple.c100,
|
logo: tokens.purple.c100,
|
||||||
text: tokens.shade.c50,
|
text: tokens.shade.c50,
|
||||||
|
@ -95,6 +95,10 @@ export default createTheme({
|
|||||||
accentB: tokens.blue.c500
|
accentB: tokens.blue.c500
|
||||||
},
|
},
|
||||||
|
|
||||||
|
modal: {
|
||||||
|
background: tokens.shade.c800,
|
||||||
|
},
|
||||||
|
|
||||||
type: {
|
type: {
|
||||||
logo: tokens.purple.c100,
|
logo: tokens.purple.c100,
|
||||||
text: tokens.shade.c50,
|
text: tokens.shade.c50,
|
||||||
|
@ -95,6 +95,10 @@ export default createTheme({
|
|||||||
accentB: tokens.blue.c500
|
accentB: tokens.blue.c500
|
||||||
},
|
},
|
||||||
|
|
||||||
|
modal: {
|
||||||
|
background: tokens.shade.c800,
|
||||||
|
},
|
||||||
|
|
||||||
type: {
|
type: {
|
||||||
logo: tokens.purple.c100,
|
logo: tokens.purple.c100,
|
||||||
text: tokens.shade.c50,
|
text: tokens.shade.c50,
|
||||||
|
Loading…
Reference in New Issue
Block a user