Browse Source

Merge pull request #760 from movie-web/extension

Browser extension & onboarding
pull/807/head
William Oldham 2 years ago committed by GitHub
parent
commit
132be80f81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      package.json
  2. 29
      pnpm-lock.yaml
  3. 104
      src/assets/locales/en.json
  4. 5
      src/backend/extension/compatibility.ts
  5. 71
      src/backend/extension/messaging.ts
  6. 68
      src/backend/extension/plasmo.ts
  7. 43
      src/backend/extension/streams.ts
  8. 2
      src/backend/helpers/fetch.ts
  9. 46
      src/backend/providers/fetchers.ts
  10. 27
      src/backend/providers/providers.ts
  11. 2
      src/components/buttons/Toggle.tsx
  12. 25
      src/components/layout/Stepper.tsx
  13. 14
      src/components/layout/ThinContainer.tsx
  14. 2
      src/components/overlays/Modal.tsx
  15. 2
      src/components/player/atoms/settings/CaptionsView.tsx
  16. 24
      src/components/player/atoms/settings/Downloads.tsx
  17. 2
      src/components/player/atoms/settings/SourceSelectingView.tsx
  18. 1
      src/components/player/display/base.ts
  19. 14
      src/components/player/hooks/useSourceSelection.ts
  20. 8
      src/components/player/internals/ScrapeCard.tsx
  21. 20
      src/components/player/internals/StatusCircle.tsx
  22. 10
      src/components/player/internals/ThumbnailScraper.tsx
  23. 2
      src/components/player/utils/convertRunoutputToSource.ts
  24. 5
      src/components/text-inputs/AuthInputBox.tsx
  25. 18
      src/components/utils/ErrorLine.tsx
  26. 4
      src/components/utils/Flare.tsx
  27. 11
      src/hooks/useProviderScrape.tsx
  28. 16
      src/hooks/useSettingsState.ts
  29. 32
      src/pages/PlayerView.tsx
  30. 15
      src/pages/Settings.tsx
  31. 28
      src/pages/layouts/MinimalPageLayout.tsx
  32. 102
      src/pages/onboarding/Onboarding.tsx
  33. 152
      src/pages/onboarding/OnboardingExtension.tsx
  34. 80
      src/pages/onboarding/OnboardingProxy.tsx
  35. 37
      src/pages/onboarding/onboardingHooks.ts
  36. 91
      src/pages/onboarding/utils.tsx
  37. 59
      src/pages/parts/player/MetaPart.tsx
  38. 2
      src/pages/parts/settings/ConnectionsPart.tsx
  39. 44
      src/pages/parts/settings/LocalePart.tsx
  40. 67
      src/pages/parts/settings/PreferencesPart.tsx
  41. 195
      src/pages/parts/settings/SetupPart.tsx
  42. 6
      src/pages/parts/settings/SidebarPart.tsx
  43. 9
      src/setup/App.tsx
  44. 4
      src/setup/config.ts
  45. 3
      src/stores/history/index.ts
  46. 22
      src/stores/onboarding/index.tsx
  47. 10
      src/stores/player/slices/source.ts
  48. 5
      src/stores/player/utils/qualities.ts
  49. 24
      src/stores/preferences/index.tsx
  50. 2
      src/utils/language.ts
  51. 23
      src/utils/onboarding.ts
  52. 20
      themes/default.ts
  53. 4
      themes/list/blue.ts
  54. 4
      themes/list/gray.ts
  55. 4
      themes/list/red.ts
  56. 4
      themes/list/teal.ts

3
package.json

@ -29,8 +29,9 @@ @@ -29,8 +29,9 @@
"@formkit/auto-animate": "^0.8.1",
"@headlessui/react": "^1.7.17",
"@ladjs/country-language": "^1.0.3",
"@movie-web/providers": "^2.0.5",
"@movie-web/providers": "^2.1.0",
"@noble/hashes": "^1.3.3",
"@plasmohq/messaging": "^0.6.1",
"@react-spring/web": "^9.7.3",
"@scure/bip39": "^1.2.2",
"@sozialhelden/ietf-language-tags": "^5.4.2",

29
pnpm-lock.yaml

@ -22,11 +22,14 @@ dependencies: @@ -22,11 +22,14 @@ dependencies:
specifier: ^1.0.3
version: 1.0.3
'@movie-web/providers':
specifier: ^2.0.5
version: 2.0.5
specifier: ^2.1.0
version: 2.1.0
'@noble/hashes':
specifier: ^1.3.3
version: 1.3.3
'@plasmohq/messaging':
specifier: ^0.6.1
version: 0.6.1(react@18.2.0)
'@react-spring/web':
specifier: ^9.7.3
version: 9.7.3(react-dom@18.2.0)(react@18.2.0)
@ -1921,8 +1924,8 @@ packages: @@ -1921,8 +1924,8 @@ packages:
engines: {node: '>= 14'}
dev: false
/@movie-web/providers@2.0.5:
resolution: {integrity: sha512-cefPTFXE7ctYeiibjk4HcNL3anRZ3lgYDAaJdzFzUrvkcSdxonP8GgGfDfPwmWWKip9dbP8Xv5aeauV/wrfaag==}
/@movie-web/providers@2.1.0:
resolution: {integrity: sha512-L7Nn5n1+0HNXha0A6bymJSGVLhyC4qd5S2r5Xk5FeqxMlqKBqOlMpUmfHiZOssog70sxTAvRfFqmKkM4UXV8kg==}
dependencies:
cheerio: 1.0.0-rc.12
crypto-js: 4.2.0
@ -1980,6 +1983,18 @@ packages: @@ -1980,6 +1983,18 @@ packages:
tslib: 2.6.2
dev: true
/@plasmohq/messaging@0.6.1(react@18.2.0):
resolution: {integrity: sha512-/nn1k8SG5z++o/NnZu+byHWcC9MhPLxfmvj+AP3buqMn7uwfYDcYWURLuMW2Knw08HBg+wku2v1Ltt4evN0nzA==}
peerDependencies:
react: ^16.8.6 || ^17 || ^18
peerDependenciesMeta:
react:
optional: true
dependencies:
nanoid: 5.0.3
react: 18.2.0
dev: false
/@react-spring/animated@9.7.3(react@18.2.0):
resolution: {integrity: sha512-5CWeNJt9pNgyvuSzQH+uy2pvTg8Y4/OisoscZIR8/ZNLIOI+CatFBhGZpDGTF/OzdNFsAoGk3wiUYTwoJ0YIvw==}
peerDependencies:
@ -5166,6 +5181,12 @@ packages: @@ -5166,6 +5181,12 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
/nanoid@5.0.3:
resolution: {integrity: sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==}
engines: {node: ^18 || >=20}
hasBin: true
dev: false
/nanoid@5.0.4:
resolution: {integrity: sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==}
engines: {node: ^18 || >=20}

104
src/assets/locales/en.json

@ -97,7 +97,8 @@ @@ -97,7 +97,8 @@
"login": "Login",
"pagetitle": "{{title}} - movie-web",
"register": "Register",
"settings": "Settings"
"settings": "Settings",
"onboarding": "Setup"
}
},
"home": {
@ -231,7 +232,7 @@ @@ -231,7 +232,7 @@
"downloadSubtitle": "Download current subtitle",
"downloadPlaylist": "Download playlist",
"downloadVideo": "Download video",
"hlsDisclaimer": "Downloads are taken directly from the provider. movie-web does not have control over how the downloads are provided. Please note that you are downloading an HLS playlist, this is intended for users familiar with advanced multimedia streaming.",
"hlsDisclaimer": "Downloads are taken directly from the provider. movie-web does not have control over how the downloads are provided.<br /><br />Please note that you are downloading an HLS playlist, it is <bold>not recommended to download if you are not familiar with advanced streaming formats</bold>. Try different sources for different formats.",
"onAndroid": {
"1": "To download on Android, click the download button then, on the new page, <bold>tap and hold</bold> on the video, then select <bold>save</bold>.",
"shortTitle": "Download / Android",
@ -276,6 +277,17 @@ @@ -276,6 +277,17 @@
"homeButton": "Back to home",
"text": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL.",
"title": "Couldn't find that media."
},
"extensionPermission": {
"badge": "Permission Missing",
"title": "Configure the extension",
"text": "You have the browser extension, but we need your permission to get started using the extension.",
"button": "Use extension"
},
"dmca": {
"badge": "Removed",
"title": "Media has been removed",
"text": "This media is no longer available due to a takedown notice or copyright claim."
}
},
"nextEpisode": {
@ -392,6 +404,28 @@ @@ -392,6 +404,28 @@
"colorLabel": "Color"
},
"connections": {
"setup": {
"errorStatus": {
"title": "Something needs your attention",
"description": "It seems that one or more items in this setup need your attention."
},
"unsetStatus": {
"title": "You haven't gone through setup",
"description": "Please click the button to the right to start the setup process."
},
"successStatus": {
"title": "Everything is set up!",
"description": "All things are in place for you to start watching your favourite media."
},
"redoSetup": "Redo setup",
"doSetup": "Do setup",
"itemError": "There is something wrong with this setting. Go through setup again to fix it.",
"items": {
"extension": "Extension",
"proxy": "Custom proxy",
"default": "Default setup"
}
},
"server": {
"description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>",
"label": "Custom server",
@ -407,10 +441,13 @@ @@ -407,10 +441,13 @@
"urlPlaceholder": "https://"
}
},
"locale": {
"preferences": {
"language": "Application language",
"languageDescription": "Language applied to the entire application.",
"title": "Locale"
"title": "Preferences",
"thumbnail": "Generate thumbnails",
"thumbnailDescription": "Most of the time, videos don't have thumbnails. You can enable this setting to generate them on the fly but they can make your video slower.",
"thumbnailLabel": "Generate thumbnails"
},
"reset": "Reset",
"save": "Save",
@ -429,5 +466,64 @@ @@ -429,5 +466,64 @@
}
},
"unsaved": "You have unsaved changes"
},
"onboarding": {
"start": {
"title": "Let's get you setup with movie-web",
"explainer": "To get the best streams possible. You will need to choose which streaming method you want to use.",
"options": {
"proxy": {
"quality": "Good quality",
"title": "Custom proxy",
"description": "Setup a proxy in just 5 minutes and gain access to great sources.",
"action": "Setup proxy"
},
"extension": {
"quality": "Best quality",
"title": "Browser extension",
"description": "Install browser extension and gain access to the best sources.",
"action": "Install extension"
},
"default": {
"text": "I don't want good quality streams,<0 /> <1>use the default setup</1>"
}
}
},
"proxy": {
"title": "Let's make a new proxy",
"explainer": "With the proxy method, you can get great quality streams by making a self-service proxy.",
"link": "Learn how to make a proxy",
"input": {
"label": "Proxy URL",
"placeholder": "https://",
"errorInvalidUrl": "Not a valid URL",
"errorConnection": "Could not connect to proxy",
"errorNotProxy": "Expected a proxy but got a website"
},
"back": "Go back",
"submit": "Submit proxy"
},
"extension": {
"title": "Let's start with an extension",
"explainer": "Using the browser extension, you can get the best streams we have to offer. With just a simple install.",
"extensionHelp": "If you've installed the extension but it's not detected. <bold>Open the extension through your browsers extension menu</bold> and follow the steps on screen.",
"link": "Install extension",
"back": "Go back",
"status": {
"loading": "Waiting for you to install the extension",
"disallowed": "Extension is not enabled for this page",
"disallowedAction": "Enable extension",
"failed": "Failed to request status",
"outdated": "Extension version too old",
"success": "Extension is working as expected!"
},
"submit": "Continue"
},
"defaultConfirm": {
"title": "Are you sure?",
"description": "The default setup does not have the best streams and can be unbearably slow.",
"cancel": "Cancel",
"confirm": "Use default setup"
}
}
}

5
src/backend/extension/compatibility.ts

@ -0,0 +1,5 @@ @@ -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

@ -0,0 +1,71 @@ @@ -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

@ -0,0 +1,68 @@ @@ -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

@ -0,0 +1,43 @@ @@ -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),
});
}

2
src/backend/helpers/fetch.ts

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { ofetch } from "ofetch";
import { getApiToken, setApiToken } from "@/backend/helpers/providerApi";
import { getLoadbalancedProxyUrl } from "@/utils/providers";
import { getLoadbalancedProxyUrl } from "@/backend/providers/fetchers";
type P<T> = Parameters<typeof ofetch<T, any>>;
type R<T> = ReturnType<typeof ofetch<T, any>>;

46
src/utils/providers.ts → src/backend/providers/fetchers.ts

@ -1,12 +1,6 @@ @@ -1,12 +1,6 @@
import {
Fetcher,
ProviderControls,
makeProviders,
makeSimpleProxyFetcher,
makeStandardFetcher,
targets,
} from "@movie-web/providers";
import { Fetcher, makeSimpleProxyFetcher } from "@movie-web/providers";
import { sendExtensionRequest } from "@/backend/extension/messaging";
import { getApiToken, setApiToken } from "@/backend/helpers/providerApi";
import { getProviderApiUrls, getProxyUrls } from "@/utils/proxyUrls";
@ -48,7 +42,7 @@ async function fetchButWithApiTokens( @@ -48,7 +42,7 @@ async function fetchButWithApiTokens(
return response;
}
function makeLoadBalancedSimpleProxyFetcher() {
export function makeLoadBalancedSimpleProxyFetcher() {
const fetcher: Fetcher = async (a, b) => {
const currentFetcher = makeSimpleProxyFetcher(
getLoadbalancedProxyUrl(),
@ -59,8 +53,32 @@ function makeLoadBalancedSimpleProxyFetcher() { @@ -59,8 +53,32 @@ function makeLoadBalancedSimpleProxyFetcher() {
return fetcher;
}
export const providers = makeProviders({
fetcher: makeStandardFetcher(fetch),
proxiedFetcher: makeLoadBalancedSimpleProxyFetcher(),
target: targets.BROWSER,
}) as any as ProviderControls;
function makeFinalHeaders(
readHeaders: string[],
headers: Record<string, string>,
): Headers {
const lowercasedHeaders = readHeaders.map((v) => v.toLowerCase());
return new Headers(
Object.entries(headers).filter((entry) =>
lowercasedHeaders.includes(entry[0].toLowerCase()),
),
);
}
export function makeExtensionFetcher() {
const fetcher: Fetcher = async (url, ops) => {
const result = (await sendExtensionRequest<any>({
url,
...ops,
})) as any;
if (!result?.success) throw new Error(`extension error: ${result?.error}`);
const res = result.response;
return {
body: res.body,
finalUrl: res.finalUrl,
statusCode: res.statusCode,
headers: makeFinalHeaders(ops.readHeaders, res.headers),
};
};
return fetcher;
}

27
src/backend/providers/providers.ts

@ -0,0 +1,27 @@ @@ -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,
});
}

2
src/components/buttons/Toggle.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import classNames from "classnames";
export function Toggle(props: { onClick: () => void; enabled?: boolean }) {
export function Toggle(props: { onClick?: () => void; enabled?: boolean }) {
return (
<button
type="button"

25
src/components/layout/Stepper.tsx

@ -0,0 +1,25 @@ @@ -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>
);
}

14
src/components/layout/ThinContainer.tsx

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

2
src/components/overlays/Modal.tsx

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

2
src/components/player/atoms/settings/CaptionsView.tsx

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

24
src/components/player/atoms/settings/Downloads.tsx

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

2
src/components/player/atoms/settings/SourceSelectingView.tsx

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

1
src/components/player/display/base.ts

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

14
src/components/player/hooks/useSourceSelection.ts

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

8
src/components/player/internals/ScrapeCard.tsx

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

20
src/components/player/internals/StatusCircle.tsx

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

10
src/components/player/internals/ThumbnailScraper.tsx

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

2
src/components/player/utils/convertRunoutputToSource.ts

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

5
src/components/text-inputs/AuthInputBox.tsx

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

18
src/components/utils/ErrorLine.tsx

@ -0,0 +1,18 @@ @@ -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>
);
}

4
src/components/utils/Flare.tsx

@ -69,7 +69,7 @@ function Light(props: FlareProps) { @@ -69,7 +69,7 @@ function Light(props: FlareProps) {
},
)}
style={{
backgroundImage: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`,
backgroundImage: `radial-gradient(circle at center, rgba(var(${cssVar}) / 1), rgba(var(${cssVar}) / 0) 70%)`,
backgroundPosition: `var(--bg-x) var(--bg-y)`,
backgroundRepeat: "no-repeat",
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
@ -85,7 +85,7 @@ function Light(props: FlareProps) { @@ -85,7 +85,7 @@ function Light(props: FlareProps) {
<div
className="absolute inset-0 opacity-10"
style={{
background: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`,
background: `radial-gradient(circle at center, rgba(var(${cssVar}) / 1), rgba(var(${cssVar}) / 0) 70%)`,
backgroundPosition: `var(--bg-x) var(--bg-y)`,
backgroundRepeat: "no-repeat",
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,

11
src/hooks/useProviderScrape.tsx

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

16
src/hooks/useSettingsState.ts

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

32
src/pages/PlayerView.tsx

@ -1,6 +1,12 @@ @@ -1,6 +1,12 @@
import { RunOutput } from "@movie-web/providers";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Navigate,
useLocation,
useNavigate,
useParams,
} from "react-router-dom";
import { useAsync } from "react-use";
import { usePlayer } from "@/components/player/hooks/usePlayer";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
@ -15,9 +21,10 @@ import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart"; @@ -15,9 +21,10 @@ import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart";
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
import { useLastNonPlayerLink } from "@/stores/history";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { needsOnboarding } from "@/utils/onboarding";
import { parseTimestamp } from "@/utils/timestamp";
export function PlayerView() {
export function RealPlayerView() {
const navigate = useNavigate();
const params = useParams<{
media: string;
@ -109,4 +116,25 @@ export function PlayerView() { @@ -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;

15
src/pages/Settings.tsx

@ -31,11 +31,12 @@ import { ThemePart } from "@/pages/parts/settings/ThemePart"; @@ -31,11 +31,12 @@ import { ThemePart } from "@/pages/parts/settings/ThemePart";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { AccountWithToken, useAuthStore } from "@/stores/auth";
import { useLanguageStore } from "@/stores/language";
import { usePreferencesStore } from "@/stores/preferences";
import { useSubtitleStore } from "@/stores/subtitles";
import { useThemeStore } from "@/stores/theme";
import { SubPageLayout } from "./layouts/SubPageLayout";
import { LocalePart } from "./parts/settings/LocalePart";
import { PreferencesPart } from "./parts/settings/PreferencesPart";
function SettingsLayout(props: { children: React.ReactNode }) {
const { isMobile } = useIsMobile();
@ -115,6 +116,9 @@ export function SettingsPage() { @@ -115,6 +116,9 @@ export function SettingsPage() {
const backendUrlSetting = useAuthStore((s) => s.backendUrl);
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
const enableThumbnails = usePreferencesStore((s) => s.enableThumbnails);
const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails);
const account = useAuthStore((s) => s.account);
const updateProfile = useAuthStore((s) => s.setAccountProfile);
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
@ -136,6 +140,7 @@ export function SettingsPage() { @@ -136,6 +140,7 @@ export function SettingsPage() {
proxySet,
backendUrlSetting,
account?.profile,
enableThumbnails,
);
const saveChanges = useCallback(async () => {
@ -168,6 +173,7 @@ export function SettingsPage() { @@ -168,6 +173,7 @@ export function SettingsPage() {
}
}
setEnableThumbnails(state.enableThumbnails.state);
setAppLanguage(state.appLanguage.state);
setTheme(state.theme.state);
setSubStyling(state.subtitleStyling.state);
@ -186,6 +192,7 @@ export function SettingsPage() { @@ -186,6 +192,7 @@ export function SettingsPage() {
state,
account,
backendUrl,
setEnableThumbnails,
setAppLanguage,
setTheme,
setSubStyling,
@ -225,10 +232,12 @@ export function SettingsPage() { @@ -225,10 +232,12 @@ export function SettingsPage() {
<RegisterCalloutPart />
)}
</div>
<div id="settings-locale" className="mt-48">
<LocalePart
<div id="settings-preferences" className="mt-48">
<PreferencesPart
language={state.appLanguage.state}
setLanguage={state.appLanguage.set}
enableThumbnails={state.enableThumbnails.state}
setEnableThumbnails={state.enableThumbnails.set}
/>
</div>
<div id="settings-appearance" className="mt-48">

28
src/pages/layouts/MinimalPageLayout.tsx

@ -0,0 +1,28 @@ @@ -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

@ -0,0 +1,102 @@ @@ -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

@ -0,0 +1,152 @@ @@ -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

@ -0,0 +1,80 @@ @@ -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

@ -0,0 +1,37 @@ @@ -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

@ -0,0 +1,91 @@ @@ -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>
);
}

59
src/pages/parts/player/MetaPart.tsx

@ -3,6 +3,8 @@ import { useNavigate, useParams } from "react-router-dom"; @@ -3,6 +3,8 @@ import { useNavigate, useParams } from "react-router-dom";
import { useAsync } from "react-use";
import type { AsyncReturnType } from "type-fest";
import { isAllowedExtensionVersion } from "@/backend/extension/compatibility";
import { extensionInfo, sendPage } from "@/backend/extension/messaging";
import {
fetchMetadata,
setCachedMetadata,
@ -10,6 +12,8 @@ import { @@ -10,6 +12,8 @@ import {
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
import { decodeTMDBId } from "@/backend/metadata/tmdb";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { getLoadbalancedProviderApiUrl } from "@/backend/providers/fetchers";
import { getProviders } from "@/backend/providers/providers";
import { Button } from "@/components/buttons/Button";
import { Icons } from "@/components/Icon";
import { IconPill } from "@/components/layout/IconPill";
@ -18,7 +22,6 @@ import { Paragraph } from "@/components/text/Paragraph"; @@ -18,7 +22,6 @@ import { Paragraph } from "@/components/text/Paragraph";
import { Title } from "@/components/text/Title";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
import { conf } from "@/setup/config";
import { getLoadbalancedProviderApiUrl, providers } from "@/utils/providers";
export interface MetaPartProps {
onGetMeta?: (meta: DetailedMeta, episodeId?: string) => void;
@ -41,8 +44,17 @@ export function MetaPart(props: MetaPartProps) { @@ -41,8 +44,17 @@ export function MetaPart(props: MetaPartProps) {
const navigate = useNavigate();
const { error, value, loading } = useAsync(async () => {
const info = await extensionInfo();
const isValidExtension =
info?.success && isAllowedExtensionVersion(info.version) && info.allowed;
if (isValidExtension) {
if (!info.hasPermission) throw new Error("extension-no-permission");
}
// use api metadata or providers metadata
const providerApiUrl = getLoadbalancedProviderApiUrl();
if (providerApiUrl) {
if (providerApiUrl && !isValidExtension) {
try {
await fetchMetadata(providerApiUrl);
} catch (err) {
@ -50,11 +62,12 @@ export function MetaPart(props: MetaPartProps) { @@ -50,11 +62,12 @@ export function MetaPart(props: MetaPartProps) {
}
} else {
setCachedMetadata([
...providers.listSources(),
...providers.listEmbeds(),
...getProviders().listSources(),
...getProviders().listEmbeds(),
]);
}
// get media meta data
let data: ReturnType<typeof decodeTMDBId> = null;
try {
if (!params.media) throw new Error("no media params");
@ -98,16 +111,42 @@ export function MetaPart(props: MetaPartProps) { @@ -98,16 +111,42 @@ export function MetaPart(props: MetaPartProps) {
props.onGetMeta?.(meta, epId);
}, []);
if (error && error.message === "extension-no-permission") {
return (
<ErrorLayout>
<ErrorContainer>
<IconPill icon={Icons.WAND}>
{t("player.metadata.extensionPermission.badge")}
</IconPill>
<Title>{t("player.metadata.extensionPermission.title")}</Title>
<Paragraph>{t("player.metadata.extensionPermission.text")}</Paragraph>
<Button
onClick={() => {
sendPage({
page: "PermissionGrant",
redirectUrl: window.location.href,
});
}}
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
{t("player.metadata.extensionPermission.button")}
</Button>
</ErrorContainer>
</ErrorLayout>
);
}
if (error && error.message === "dmca") {
return (
<ErrorLayout>
<ErrorContainer>
<IconPill icon={Icons.DRAGON}>Removed</IconPill>
<Title>Media has been removed</Title>
<Paragraph>
This media is no longer available due to a takedown notice or
copyright claim.
</Paragraph>
<IconPill icon={Icons.DRAGON}>
{t("player.metadata.dmca.badge")}
</IconPill>
<Title>{t("player.metadata.dmca.title")}</Title>
<Paragraph>{t("player.metadata.dmca.text")}</Paragraph>
<Button
href="/"
theme="purple"

2
src/pages/parts/settings/ConnectionsPart.tsx

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

44
src/pages/parts/settings/LocalePart.tsx

@ -1,44 +0,0 @@ @@ -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

@ -0,0 +1,67 @@ @@ -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

@ -0,0 +1,195 @@ @@ -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>
);
}

6
src/pages/parts/settings/SidebarPart.tsx

@ -44,9 +44,9 @@ export function SidebarPart() { @@ -44,9 +44,9 @@ export function SidebarPart() {
icon: Icons.USER,
},
{
textKey: "settings.locale.title",
id: "settings-locale",
icon: Icons.BOOKMARK,
textKey: "settings.preferences.title",
id: "settings-preferences",
icon: Icons.SETTINGS,
},
{
textKey: "settings.appearance.title",

9
src/setup/App.tsx

@ -19,6 +19,9 @@ import { DmcaPage, shouldHaveDmcaPage } from "@/pages/Dmca"; @@ -19,6 +19,9 @@ import { DmcaPage, shouldHaveDmcaPage } from "@/pages/Dmca";
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage";
import { LoginPage } from "@/pages/Login";
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
import { RegisterPage } from "@/pages/Register";
import { Layout } from "@/setup/Layout";
import { useHistoryListener } from "@/stores/history";
@ -119,6 +122,12 @@ function App() { @@ -119,6 +122,12 @@ function App() {
<Route path="/register" element={<RegisterPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route
path="/onboarding/extension"
element={<OnboardingExtensionPage />}
/>
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
{shouldHaveDmcaPage() ? (
<Route path="/dmca" element={<DmcaPage />} />

4
src/setup/config.ts

@ -19,6 +19,7 @@ interface Config { @@ -19,6 +19,7 @@ interface Config {
DISALLOWED_IDS: string;
TURNSTILE_KEY: string;
CDN_REPLACEMENTS: string;
HAS_ONBOARDING: string;
}
export interface RuntimeConfig {
@ -34,6 +35,7 @@ export interface RuntimeConfig { @@ -34,6 +35,7 @@ export interface RuntimeConfig {
DISALLOWED_IDS: string[];
TURNSTILE_KEY: string | null;
CDN_REPLACEMENTS: Array<string[]>;
HAS_ONBOARDING: boolean;
}
const env: Record<keyof Config, undefined | string> = {
@ -49,6 +51,7 @@ const env: Record<keyof Config, undefined | string> = { @@ -49,6 +51,7 @@ const env: Record<keyof Config, undefined | string> = {
DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS,
TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY,
CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS,
HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING,
};
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
@ -82,6 +85,7 @@ export function conf(): RuntimeConfig { @@ -82,6 +85,7 @@ export function conf(): RuntimeConfig {
.split(",")
.map((v) => v.trim()),
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true",
TURNSTILE_KEY: turnstileKey.length > 0 ? turnstileKey : null,
DISALLOWED_IDS: getKey("DISALLOWED_IDS", "")
.split(",")

3
src/stores/history/index.ts

@ -46,7 +46,8 @@ export function useLastNonPlayerLink() { @@ -46,7 +46,8 @@ export function useLastNonPlayerLink() {
(v) =>
!v.path.startsWith("/media") && // cannot be a player link
location.pathname !== v.path && // cannot be current link
!v.path.startsWith("/s/"), // cannot be a quick search link
!v.path.startsWith("/s/") && // cannot be a quick search link
!v.path.startsWith("/onboarding"), // cannot be an onboarding link
);
return route?.path ?? "/";
}, [routes, location]);

22
src/stores/onboarding/index.tsx

@ -0,0 +1,22 @@ @@ -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" },
),
);

10
src/stores/player/slices/source.ts

@ -118,6 +118,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({ @@ -118,6 +118,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
},
setSourceId(id) {
set((s) => {
s.status = playerStatus.PLAYING;
s.sourceId = id;
});
},
@ -155,6 +156,8 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({ @@ -155,6 +156,8 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.qualities = qualities as SourceQuality[];
s.currentQuality = loadableStream.quality;
s.captionList = captions;
s.interface.error = undefined;
s.status = playerStatus.PLAYING;
});
const store = get();
store.redisplaySource(startAt);
@ -168,7 +171,10 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({ @@ -168,7 +171,10 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
automaticQuality: qualityPreferences.quality.automaticQuality,
lastChosenQuality: quality,
});
set((s) => {
s.interface.error = undefined;
s.status = playerStatus.PLAYING;
});
store.display?.load({
source: loadableStream.stream,
startAt,
@ -184,6 +190,8 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({ @@ -184,6 +190,8 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
if (!selectedQuality) return;
set((s) => {
s.currentQuality = quality;
s.status = playerStatus.PLAYING;
s.interface.error = undefined;
});
store.display?.load({
source: selectedQuality,

5
src/stores/player/utils/qualities.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { Qualities } from "@movie-web/providers";
import { Qualities, Stream } from "@movie-web/providers";
import { QualityStore } from "@/stores/quality";
@ -14,16 +14,19 @@ export type SourceFileStream = { @@ -14,16 +14,19 @@ export type SourceFileStream = {
export type LoadableSource = {
type: StreamType;
url: string;
preferredHeaders?: Stream["preferredHeaders"];
};
export type SourceSliceSource =
| {
type: "file";
qualities: Partial<Record<SourceQuality, SourceFileStream>>;
preferredHeaders?: Stream["preferredHeaders"];
}
| {
type: "hls";
url: string;
preferredHeaders?: Stream["preferredHeaders"];
};
const qualitySorting: Record<SourceQuality, number> = {

24
src/stores/preferences/index.tsx

@ -0,0 +1,24 @@ @@ -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",
},
),
);

2
src/utils/language.ts

@ -86,7 +86,7 @@ function populateLanguageCode(language: string): string { @@ -86,7 +86,7 @@ function populateLanguageCode(language: string): string {
* @returns pretty format for language, null if it no info can be found for language
*/
export function getPrettyLanguageNameFromLocale(locale: string): string | null {
const tag = getTag(populateLanguageCode(locale), true);
const tag = getTag(locale, true);
const lang = tag?.language?.Description?.[0] ?? null;
if (!lang) return null;

23
src/utils/onboarding.ts

@ -0,0 +1,23 @@ @@ -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;
}

20
themes/default.ts

@ -138,6 +138,11 @@ export const defaultTheme = { @@ -138,6 +138,11 @@ export const defaultTheme = {
accentB: tokens.blue.c500,
},
// Modals
modal: {
background: tokens.shade.c800,
},
// typography
type: {
logo: tokens.purple.c100,
@ -147,6 +152,7 @@ export const defaultTheme = { @@ -147,6 +152,7 @@ export const defaultTheme = {
divider: tokens.ash.c500,
secondary: tokens.ash.c100,
danger: tokens.semantic.red.c100,
success: tokens.semantic.green.c100,
link: tokens.purple.c100,
linkHover: tokens.purple.c50,
},
@ -228,10 +234,24 @@ export const defaultTheme = { @@ -228,10 +234,24 @@ export const defaultTheme = {
}
},
// Utilities
utils: {
divider: tokens.ash.c300,
},
// Onboarding
onboarding: {
bar: tokens.shade.c400,
barFilled: tokens.purple.c300,
divider: tokens.shade.c200,
card: tokens.shade.c800,
cardHover: tokens.shade.c700,
border: tokens.shade.c600,
good: tokens.purple.c100,
best: tokens.semantic.yellow.c100,
link: tokens.purple.c100,
},
// Error page
errors: {
card: tokens.shade.c800,

4
themes/list/blue.ts

@ -95,6 +95,10 @@ export default createTheme({ @@ -95,6 +95,10 @@ export default createTheme({
accentB: tokens.blue.c500
},
modal: {
background: tokens.shade.c800,
},
type: {
logo: tokens.purple.c100,
text: tokens.shade.c50,

4
themes/list/gray.ts

@ -95,6 +95,10 @@ export default createTheme({ @@ -95,6 +95,10 @@ export default createTheme({
accentB: tokens.blue.c500
},
modal: {
background: tokens.shade.c800,
},
type: {
logo: tokens.purple.c100,
text: tokens.shade.c50,

4
themes/list/red.ts

@ -95,6 +95,10 @@ export default createTheme({ @@ -95,6 +95,10 @@ export default createTheme({
accentB: tokens.blue.c500
},
modal: {
background: tokens.shade.c800,
},
type: {
logo: tokens.purple.c100,
text: tokens.shade.c50,

4
themes/list/teal.ts

@ -95,6 +95,10 @@ export default createTheme({ @@ -95,6 +95,10 @@ export default createTheme({
accentB: tokens.blue.c500
},
modal: {
background: tokens.shade.c800,
},
type: {
logo: tokens.purple.c100,
text: tokens.shade.c50,

Loading…
Cancel
Save