Merge branch 'dev' into dev

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

View File

@ -29,8 +29,9 @@
"@formkit/auto-animate": "^0.8.1", "@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",

View File

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

View File

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

View File

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

View File

@ -0,0 +1,71 @@
import {
MessagesMetadata,
sendToBackgroundViaRelay,
} from "@plasmohq/messaging";
import { isAllowedExtensionVersion } from "@/backend/extension/compatibility";
import { ExtensionMakeRequestResponse } from "@/backend/extension/plasmo";
let activeExtension = false;
function sendMessage<MessageKey extends keyof MessagesMetadata>(
message: MessageKey,
payload: MessagesMetadata[MessageKey]["req"] | undefined = undefined,
timeout: number = -1,
) {
return new Promise<MessagesMetadata[MessageKey]["res"] | null>((resolve) => {
if (timeout >= 0) setTimeout(() => resolve(null), timeout);
sendToBackgroundViaRelay<
MessagesMetadata[MessageKey]["req"],
MessagesMetadata[MessageKey]["res"]
>({
name: message,
body: payload,
})
.then((res) => {
activeExtension = true;
resolve(res);
})
.catch(() => {
activeExtension = false;
resolve(null);
});
});
}
export async function sendExtensionRequest<T>(
ops: MessagesMetadata["makeRequest"]["req"],
): Promise<ExtensionMakeRequestResponse<T> | null> {
return sendMessage("makeRequest", ops);
}
export async function setDomainRule(
ops: MessagesMetadata["prepareStream"]["req"],
): Promise<MessagesMetadata["prepareStream"]["res"] | null> {
return sendMessage("prepareStream", ops);
}
export async function sendPage(
ops: MessagesMetadata["openPage"]["req"],
): Promise<MessagesMetadata["openPage"]["res"] | null> {
return sendMessage("openPage", ops);
}
export async function extensionInfo(): Promise<
MessagesMetadata["hello"]["res"] | null
> {
const message = await sendMessage("hello", undefined, 300);
return message;
}
export function isExtensionActiveCached(): boolean {
return activeExtension;
}
export async function isExtensionActive(): Promise<boolean> {
const info = await extensionInfo();
if (!info?.success) return false;
const allowedVersion = isAllowedExtensionVersion(info.version);
if (!allowedVersion) return false;
return info.allowed && info.hasPermission;
}

View File

@ -0,0 +1,68 @@
export interface ExtensionBaseRequest {}
export type ExtensionBaseResponse<T = object> =
| ({
success: true;
} & T)
| {
success: false;
error: string;
};
export type ExtensionHelloResponse = ExtensionBaseResponse<{
version: string;
allowed: boolean;
hasPermission: boolean;
}>;
export interface ExtensionMakeRequest extends ExtensionBaseRequest {
url: string;
method: string;
headers?: Record<string, string>;
body?: string | FormData | URLSearchParams | Record<string, any>;
}
export type ExtensionMakeRequestResponse<T> = ExtensionBaseResponse<{
response: {
statusCode: number;
headers: Record<string, string>;
finalUrl: string;
body: T;
};
}>;
export interface ExtensionPrepareStreamRequest extends ExtensionBaseRequest {
ruleId: number;
targetDomains: string[];
requestHeaders?: Record<string, string>;
responseHeaders?: Record<string, string>;
}
export interface MmMetadata {
hello: {
req: ExtensionBaseRequest;
res: ExtensionHelloResponse;
};
makeRequest: {
req: ExtensionMakeRequest;
res: ExtensionMakeRequestResponse<any>;
};
prepareStream: {
req: ExtensionPrepareStreamRequest;
res: ExtensionBaseResponse;
};
openPage: {
req: ExtensionBaseRequest & {
page: string;
redirectUrl: string;
};
res: ExtensionBaseResponse;
};
}
interface MpMetadata {}
declare module "@plasmohq/messaging" {
interface MessagesMetadata extends MmMetadata {}
interface PortsMetadata extends MpMetadata {}
}

View File

@ -0,0 +1,43 @@
import { Stream } from "@movie-web/providers";
import { setDomainRule } from "@/backend/extension/messaging";
function extractDomain(url: string): string | null {
try {
const u = new URL(url);
return u.hostname;
} catch {
return null;
}
}
function extractDomainsFromStream(stream: Stream): string[] {
if (stream.type === "hls") {
return [extractDomain(stream.playlist)].filter((v): v is string => !!v);
}
if (stream.type === "file") {
return Object.values(stream.qualities)
.map((v) => extractDomain(v.url))
.filter((v): v is string => !!v);
}
return [];
}
function buildHeadersFromStream(stream: Stream): Record<string, string> {
const headers: Record<string, string> = {};
Object.entries(stream.headers ?? {}).forEach((entry) => {
headers[entry[0]] = entry[1];
});
Object.entries(stream.preferredHeaders ?? {}).forEach((entry) => {
headers[entry[0]] = entry[1];
});
return headers;
}
export async function prepareStream(stream: Stream) {
await setDomainRule({
ruleId: 1,
targetDomains: extractDomainsFromStream(stream),
requestHeaders: buildHeadersFromStream(stream),
});
}

View File

@ -1,7 +1,7 @@
import { ofetch } from "ofetch"; import { 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>>;

View File

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

View File

@ -0,0 +1,27 @@
import {
makeProviders,
makeStandardFetcher,
targets,
} from "@movie-web/providers";
import { isExtensionActiveCached } from "@/backend/extension/messaging";
import {
makeExtensionFetcher,
makeLoadBalancedSimpleProxyFetcher,
} from "@/backend/providers/fetchers";
export function getProviders() {
if (isExtensionActiveCached()) {
return makeProviders({
fetcher: makeExtensionFetcher(),
target: targets.BROWSER_EXTENSION,
consistentIpForRequests: true,
});
}
return makeProviders({
fetcher: makeStandardFetcher(fetch),
proxiedFetcher: makeLoadBalancedSimpleProxyFetcher(),
target: targets.BROWSER,
});
}

View File

@ -1,6 +1,6 @@
import classNames from "classnames"; 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"

View File

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

View File

@ -1,3 +1,4 @@
import classNames from "classnames";
import { ReactNode } from "react"; 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
import classNames from "classnames";
import { ReactNode } from "react";
import { Icon, Icons } from "@/components/Icon";
export function ErrorLine(props: { children?: ReactNode; className?: string }) {
return (
<p
className={classNames(
"inline-flex items-center text-type-danger",
props.className,
)}
>
<Icon icon={Icons.WARNING} className="text-xl mr-4" />
{props.children}
</p>
);
}

View File

@ -69,7 +69,7 @@ function Light(props: FlareProps) {
}, },
)} )}
style={{ 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`,

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
import { Link } from "react-router-dom";
import { BrandPill } from "@/components/layout/BrandPill";
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
export function MinimalPageLayout(props: { children: React.ReactNode }) {
return (
<div
className="bg-background-main min-h-screen"
style={{
backgroundImage:
"linear-gradient(to bottom, var(--tw-gradient-from), var(--tw-gradient-to) 800px)",
}}
>
<BlurEllipsis />
{/* Main page */}
<div className="fixed px-7 py-5 left-0 top-0">
<Link
className="block tabbable rounded-full text-xs ssm:text-base"
to="/"
>
<BrandPill clickable />
</Link>
</div>
<div className="min-h-screen">{props.children}</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,152 @@
import { ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useAsyncFn, useInterval } from "react-use";
import { isAllowedExtensionVersion } from "@/backend/extension/compatibility";
import { extensionInfo, sendPage } from "@/backend/extension/messaging";
import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon";
import { Loading } from "@/components/layout/Loading";
import { Stepper } from "@/components/layout/Stepper";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
import {
useNavigateOnboarding,
useRedirectBack,
} from "@/pages/onboarding/onboardingHooks";
import { Card, Link } from "@/pages/onboarding/utils";
import { PageTitle } from "@/pages/parts/util/PageTitle";
type ExtensionStatus =
| "unknown"
| "failed"
| "disallowed"
| "noperms"
| "outdated"
| "success";
async function getExtensionState(): Promise<ExtensionStatus> {
const info = await extensionInfo();
if (!info) return "unknown"; // cant talk to extension
if (!info.success) return "failed"; // extension failed to respond
if (!info.allowed) return "disallowed"; // extension is not enabled on this page
if (!info.hasPermission) return "noperms"; // extension has no perms to do it's tasks
if (!isAllowedExtensionVersion(info.version)) return "outdated"; // extension is too old
return "success"; // no problems
}
export function ExtensionStatus(props: {
status: ExtensionStatus;
loading: boolean;
}) {
const { t } = useTranslation();
let content: ReactNode = null;
if (props.loading || props.status === "unknown")
content = (
<>
<Loading />
<p>{t("onboarding.extension.status.loading")}</p>
</>
);
if (props.status === "disallowed" || props.status === "noperms")
content = (
<>
<p>{t("onboarding.extension.status.disallowed")}</p>
<Button
onClick={() => {
sendPage({
page: "PermissionGrant",
redirectUrl: window.location.href,
});
}}
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("onboarding.extension.status.disallowedAction")}
</Button>
</>
);
else if (props.status === "failed")
content = <p>{t("onboarding.extension.status.failed")}</p>;
else if (props.status === "outdated")
content = <p>{t("onboarding.extension.status.outdated")}</p>;
else if (props.status === "success")
content = (
<p className="flex items-center">
<Icon icon={Icons.CHECKMARK} className="text-type-success mr-4" />
{t("onboarding.extension.status.success")}
</p>
);
return (
<>
<Card>
<div className="flex py-6 flex-col space-y-2 items-center justify-center">
{content}
</div>
</Card>
<Card className="mt-4">
<div className="flex items-center space-x-7">
<Icon icon={Icons.WARNING} className="text-type-danger text-2xl" />
<p className="flex-1">
<Trans
i18nKey="onboarding.extension.extensionHelp"
components={{
bold: <span className="text-white" />,
}}
/>
</p>
</div>
</Card>
</>
);
}
export function OnboardingExtensionPage() {
const { t } = useTranslation();
const navigate = useNavigateOnboarding();
const { completeAndRedirect } = useRedirectBack();
const [{ loading, value }, exec] = useAsyncFn(
async (triggeredManually: boolean = false) => {
const status = await getExtensionState();
if (status === "success" && triggeredManually) completeAndRedirect();
return status;
},
[completeAndRedirect],
);
useInterval(exec, 1000);
// TODO proper link to install extension
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.onboarding" />
<CenterContainer>
<Stepper steps={2} current={2} className="mb-12" />
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
{t("onboarding.extension.title")}
</Heading2>
<Paragraph className="max-w-[320px] mb-4">
{t("onboarding.extension.explainer")}
</Paragraph>
<Link href="https://google.com" target="_blank" className="mb-12">
{t("onboarding.extension.link")}
</Link>
<ExtensionStatus status={value ?? "unknown"} loading={loading} />
<div className="flex justify-between items-center mt-8">
<Button onClick={() => navigate("/onboarding")} theme="secondary">
{t("onboarding.extension.back")}
</Button>
{value === "success" ? (
<Button onClick={() => exec(true)} theme="purple">
{t("onboarding.extension.submit")}
</Button>
) : null}
</div>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@ -0,0 +1,80 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncFn } from "react-use";
import { singularProxiedFetch } from "@/backend/helpers/fetch";
import { Button } from "@/components/buttons/Button";
import { Stepper } from "@/components/layout/Stepper";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { Divider } from "@/components/utils/Divider";
import { ErrorLine } from "@/components/utils/ErrorLine";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
import {
useNavigateOnboarding,
useRedirectBack,
} from "@/pages/onboarding/onboardingHooks";
import { Link } from "@/pages/onboarding/utils";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { useAuthStore } from "@/stores/auth";
const testUrl = "https://postman-echo.com/get";
export function OnboardingProxyPage() {
const { t } = useTranslation();
const navigate = useNavigateOnboarding();
const { completeAndRedirect } = useRedirectBack();
const [url, setUrl] = useState("");
const setProxySet = useAuthStore((s) => s.setProxySet);
const [{ loading, error }, test] = useAsyncFn(async () => {
if (!url.startsWith("http"))
throw new Error("onboarding.proxy.input.errorInvalidUrl");
try {
const res = await singularProxiedFetch(url, testUrl, {});
if (res.url !== testUrl)
throw new Error("onboarding.proxy.input.errorNotProxy");
setProxySet([url]);
completeAndRedirect();
} catch (e) {
throw new Error("onboarding.proxy.input.errorConnection");
}
}, [url, completeAndRedirect, setProxySet]);
// TODO proper link to proxy deployment docs
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.onboarding" />
<CenterContainer>
<Stepper steps={2} current={2} className="mb-12" />
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
{t("onboarding.proxy.title")}
</Heading2>
<Paragraph className="max-w-[320px] !mb-5">
{t("onboarding.proxy.explainer")}
</Paragraph>
<Link>{t("onboarding.proxy.link")}</Link>
<div className="w-[400px] max-w-full mt-14 mb-28">
<AuthInputBox
label={t("onboarding.proxy.input.label")}
value={url}
onChange={setUrl}
placeholder={t("onboarding.proxy.input.placeholder")}
className="mb-4"
/>
{error ? <ErrorLine>{t(error.message)}</ErrorLine> : null}
</div>
<Divider />
<div className="flex justify-between">
<Button theme="secondary" onClick={() => navigate("/onboarding")}>
{t("onboarding.proxy.back")}
</Button>
<Button theme="purple" loading={loading} onClick={test}>
{t("onboarding.proxy.submit")}
</Button>
</div>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@ -0,0 +1,37 @@
import { useCallback } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useQueryParam } from "@/hooks/useQueryParams";
import { useOnboardingStore } from "@/stores/onboarding";
export function useRedirectBack() {
const [url] = useQueryParam("redirect");
const navigate = useNavigate();
const setCompleted = useOnboardingStore((s) => s.setCompleted);
const redirectBack = useCallback(() => {
navigate(url ?? "/");
}, [navigate, url]);
const completeAndRedirect = useCallback(() => {
setCompleted(true);
redirectBack();
}, [redirectBack, setCompleted]);
return { completeAndRedirect };
}
export function useNavigateOnboarding() {
const navigate = useNavigate();
const loc = useLocation();
const nav = useCallback(
(path: string) => {
navigate({
pathname: path,
search: loc.search,
});
},
[navigate, loc],
);
return nav;
}

View File

@ -0,0 +1,91 @@
import classNames from "classnames";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { Heading2, Heading3, Paragraph } from "@/components/utils/Text";
export function Card(props: {
children?: React.ReactNode;
className?: string;
onClick?: () => void;
}) {
return (
<div
className={classNames(
{
"bg-onboarding-card duration-300 border border-onboarding-border rounded-lg p-7":
true,
"hover:bg-onboarding-cardHover transition-colors cursor-pointer":
!!props.onClick,
},
props.className,
)}
onClick={props.onClick}
>
{props.children}
</div>
);
}
export function CardContent(props: {
title: ReactNode;
description: ReactNode;
subtitle: ReactNode;
colorClass: string;
children?: React.ReactNode;
}) {
return (
<div className="grid grid-rows-[1fr,auto] h-full">
<div>
<Icon
icon={Icons.RISING_STAR}
className={classNames("text-4xl mb-8 block", props.colorClass)}
/>
<Heading3
className={classNames(
"!mt-0 !mb-0 !text-xs uppercase",
props.colorClass,
)}
>
{props.subtitle}
</Heading3>
<Heading2 className="!mb-0 !mt-1 !text-base">{props.title}</Heading2>
<Paragraph className="max-w-[320px] !my-4">
{props.description}
</Paragraph>
</div>
<div>{props.children}</div>
</div>
);
}
export function Link(props: {
children?: React.ReactNode;
to?: string;
href?: string;
className?: string;
target?: "_blank";
}) {
const navigate = useNavigate();
return (
<a
onClick={() => {
if (props.to) navigate(props.to);
}}
href={props.href}
target={props.target}
className={classNames(
"text-onboarding-link cursor-pointer inline-flex gap-2 items-center group hover:opacity-75 transition-opacity",
props.className,
)}
rel="noreferrer"
>
{props.children}
<Icon
icon={Icons.ARROW_RIGHT}
className="group-hover:translate-x-0.5 transition-transform text-xl group-active:translate-x-0"
/>
</a>
);
}

View File

@ -3,6 +3,8 @@ import { useNavigate, useParams } from "react-router-dom";
import { useAsync } from "react-use"; import { 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"

View File

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

View File

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

View File

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

View File

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

View File

@ -44,9 +44,9 @@ export function SidebarPart() {
icon: Icons.USER, 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",

View File

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

View File

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

View File

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

View File

@ -0,0 +1,22 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
export interface OnboardingStore {
completed: boolean;
setCompleted(v: boolean): void;
}
export const useOnboardingStore = create(
persist(
immer<OnboardingStore>((set) => ({
completed: false,
setCompleted(v) {
set((s) => {
s.completed = v;
});
},
})),
{ name: "__MW::onboarding" },
),
);

View File

@ -118,6 +118,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
}, },
setSourceId(id) { 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,

View File

@ -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> = {

View File

@ -0,0 +1,24 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
export interface PreferencesStore {
enableThumbnails: boolean;
setEnableThumbnails(v: boolean): void;
}
export const usePreferencesStore = create(
persist(
immer<PreferencesStore>((set) => ({
enableThumbnails: false,
setEnableThumbnails(v) {
set((s) => {
s.enableThumbnails = v;
});
},
})),
{
name: "__MW::preferences",
},
),
);

View File

@ -86,7 +86,7 @@ function populateLanguageCode(language: string): string {
* @returns pretty format for language, null if it no info can be found for language * @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
View File

@ -0,0 +1,23 @@
import { isExtensionActive } from "@/backend/extension/messaging";
import { conf } from "@/setup/config";
import { useAuthStore } from "@/stores/auth";
import { useOnboardingStore } from "@/stores/onboarding";
export async function needsOnboarding(): Promise<boolean> {
// if onboarding is dislabed, no onboarding needed
if (!conf().HAS_ONBOARDING) return false;
// if extension is active and working, no onboarding needed
const extensionActive = await isExtensionActive();
if (extensionActive) return false;
// if there is any custom proxy urls, no onboarding needed
const proxyUrls = useAuthStore.getState().proxySet;
if (proxyUrls) return false;
// if onboarding has been completed, no onboarding needed
const completed = useOnboardingStore.getState().completed;
if (completed) return false;
return true;
}

View File

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

View File

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

View File

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

View File

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

View File

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