diff --git a/package.json b/package.json
index 9389abee..39029b7b 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"@scure/bip39": "^1.2.2",
"@sozialhelden/ietf-language-tags": "^5.4.2",
"@types/node-forge": "^1.3.10",
+ "@vercel/analytics": "^1.2.2",
"classnames": "^2.3.2",
"core-js": "^3.34.0",
"detect-browser": "^5.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 97b59603..35e43c3c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -42,6 +42,9 @@ dependencies:
'@types/node-forge':
specifier: ^1.3.10
version: 1.3.10
+ '@vercel/analytics':
+ specifier: ^1.2.2
+ version: 1.2.2(react@18.2.0)
classnames:
specifier: ^2.3.2
version: 2.3.2
@@ -2488,6 +2491,21 @@ packages:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
dev: true
+ /@vercel/analytics@1.2.2(react@18.2.0):
+ resolution: {integrity: sha512-X0rctVWkQV1e5Y300ehVNqpOfSOufo7ieA5PIdna8yX/U7Vjz0GFsGf4qvAhxV02uQ2CVt7GYcrFfddXXK2Y4A==}
+ peerDependencies:
+ next: '>= 13'
+ react: ^18 || ^19
+ peerDependenciesMeta:
+ next:
+ optional: true
+ react:
+ optional: true
+ dependencies:
+ react: 18.2.0
+ server-only: 0.0.1
+ dev: false
+
/@vitejs/plugin-react@4.2.1(vite@5.0.12):
resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -6172,6 +6190,10 @@ packages:
randombytes: 2.1.0
dev: true
+ /server-only@0.0.1:
+ resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
+ dev: false
+
/set-cookie-parser@2.6.0:
resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==}
dev: false
diff --git a/src/setup/App.tsx b/src/setup/App.tsx
index 37c55e0f..5c1d01d7 100644
--- a/src/setup/App.tsx
+++ b/src/setup/App.tsx
@@ -1,3 +1,4 @@
+import { Analytics } from "@vercel/analytics/react";
import { ReactElement, Suspense, lazy, useEffect } from "react";
import { lazyWithPreload } from "react-lazy-with-preload";
import {
@@ -155,6 +156,7 @@ function App() {
) : null}
} />
+
);
}
diff --git a/src/utils/setup/App.tsx b/src/utils/setup/App.tsx
new file mode 100644
index 00000000..37c55e0f
--- /dev/null
+++ b/src/utils/setup/App.tsx
@@ -0,0 +1,162 @@
+import { ReactElement, Suspense, lazy, useEffect } from "react";
+import { lazyWithPreload } from "react-lazy-with-preload";
+import {
+ Navigate,
+ Route,
+ Routes,
+ useLocation,
+ useNavigate,
+ useParams,
+} from "react-router-dom";
+
+import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
+import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
+import { useOnlineListener } from "@/hooks/usePing";
+import { AboutPage } from "@/pages/About";
+import { AdminPage } from "@/pages/admin/AdminPage";
+import VideoTesterView from "@/pages/developer/VideoTesterView";
+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";
+import { LanguageProvider } from "@/stores/language";
+
+const DeveloperPage = lazy(() => import("@/pages/DeveloperPage"));
+const TestView = lazy(() => import("@/pages/developer/TestView"));
+const PlayerView = lazyWithPreload(() => import("@/pages/PlayerView"));
+const SettingsPage = lazyWithPreload(() => import("@/pages/Settings"));
+
+PlayerView.preload();
+SettingsPage.preload();
+
+function LegacyUrlView({ children }: { children: ReactElement }) {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const url = location.pathname;
+ if (!isLegacyUrl(url)) return;
+ convertLegacyUrl(location.pathname).then((convertedUrl) => {
+ navigate(convertedUrl ?? "/", { replace: true });
+ });
+ }, [location.pathname, navigate]);
+
+ if (isLegacyUrl(location.pathname)) return null;
+ return children;
+}
+
+function QuickSearch() {
+ const { query } = useParams<{ query: string }>();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (query) {
+ generateQuickSearchMediaUrl(query).then((url) => {
+ navigate(url ?? "/", { replace: true });
+ });
+ } else {
+ navigate("/", { replace: true });
+ }
+ }, [query, navigate]);
+
+ return null;
+}
+
+function QueryView() {
+ const { query } = useParams<{ query: string }>();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (query) {
+ navigate(`/browse/${query}`, { replace: true });
+ } else {
+ navigate("/", { replace: true });
+ }
+ }, [query, navigate]);
+
+ return null;
+}
+
+function App() {
+ useHistoryListener();
+ useOnlineListener();
+
+ return (
+
+
+
+ {/* functional routes */}
+ } />
+ } />
+ } />
+
+ {/* pages */}
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+
+ {shouldHaveDmcaPage() ? (
+ } />
+ ) : null}
+
+ {/* Settings page */}
+
+
+
+ }
+ />
+
+ {/* admin routes */}
+ } />
+
+ {/* other */}
+ } />
+ } />
+ {/* developer routes that can abuse workers are disabled in production */}
+ {process.env.NODE_ENV === "development" ? (
+ } />
+ ) : null}
+ } />
+
+
+ );
+}
+
+export default App;
diff --git a/src/utils/setup/Layout.tsx b/src/utils/setup/Layout.tsx
new file mode 100644
index 00000000..3227ecbc
--- /dev/null
+++ b/src/utils/setup/Layout.tsx
@@ -0,0 +1,25 @@
+import { ReactNode } from "react";
+
+import { useBannerSize, useBannerStore } from "@/stores/banner";
+import { BannerLocation } from "@/stores/banner/BannerLocation";
+
+export function Layout(props: { children: ReactNode }) {
+ const bannerSize = useBannerSize();
+ const location = useBannerStore((s) => s.location);
+
+ return (
+
+
+
+
+
+ {props.children}
+
+
+ );
+}
diff --git a/src/utils/setup/chromecast.ts b/src/utils/setup/chromecast.ts
new file mode 100644
index 00000000..9c288629
--- /dev/null
+++ b/src/utils/setup/chromecast.ts
@@ -0,0 +1,30 @@
+const CHROMECAST_SENDER_SDK =
+ "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
+
+const callbacks: ((available: boolean) => void)[] = [];
+let _available: boolean | null = null;
+
+function init(available: boolean) {
+ _available = available;
+ callbacks.forEach((cb) => cb(available));
+}
+
+export function isChromecastAvailable(cb: (available: boolean) => void) {
+ if (_available !== null) return cb(_available);
+ callbacks.push(cb);
+}
+
+export function initializeChromecast() {
+ window.__onGCastApiAvailable = (isAvailable) => {
+ init(isAvailable);
+ };
+
+ // add script if doesnt exist yet
+ const exists = !!document.getElementById("chromecast-script");
+ if (!exists) {
+ const script = document.createElement("script");
+ script.setAttribute("src", CHROMECAST_SENDER_SDK);
+ script.setAttribute("id", "chromecast-script");
+ document.body.appendChild(script);
+ }
+}
diff --git a/src/utils/setup/config.ts b/src/utils/setup/config.ts
new file mode 100644
index 00000000..f081e673
--- /dev/null
+++ b/src/utils/setup/config.ts
@@ -0,0 +1,125 @@
+import {
+ APP_VERSION,
+ BACKEND_URL,
+ DISCORD_LINK,
+ DONATION_LINK,
+ GITHUB_LINK,
+} from "./constants";
+
+interface Config {
+ APP_VERSION: string;
+ GITHUB_LINK: string;
+ DONATION_LINK: string;
+ DISCORD_LINK: string;
+ DMCA_EMAIL: string;
+ TMDB_READ_API_KEY: string;
+ CORS_PROXY_URL: string;
+ NORMAL_ROUTER: boolean;
+ BACKEND_URL: string;
+ DISALLOWED_IDS: string;
+ TURNSTILE_KEY: string;
+ CDN_REPLACEMENTS: string;
+ HAS_ONBOARDING: string;
+ ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string;
+ ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK: string;
+ ONBOARDING_PROXY_INSTALL_LINK: string;
+}
+
+export interface RuntimeConfig {
+ APP_VERSION: string;
+ GITHUB_LINK: string;
+ DONATION_LINK: string;
+ DISCORD_LINK: string;
+ DMCA_EMAIL: string | null;
+ TMDB_READ_API_KEY: string;
+ NORMAL_ROUTER: boolean;
+ PROXY_URLS: string[];
+ BACKEND_URL: string;
+ DISALLOWED_IDS: string[];
+ TURNSTILE_KEY: string | null;
+ CDN_REPLACEMENTS: Array;
+ HAS_ONBOARDING: boolean;
+ ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string | null;
+ ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK: string | null;
+ ONBOARDING_PROXY_INSTALL_LINK: string | null;
+}
+
+const env: Record = {
+ TMDB_READ_API_KEY: import.meta.env.VITE_TMDB_READ_API_KEY,
+ APP_VERSION: undefined,
+ GITHUB_LINK: undefined,
+ DONATION_LINK: undefined,
+ DISCORD_LINK: undefined,
+ ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: import.meta.env
+ .VITE_ONBOARDING_CHROME_EXTENSION_INSTALL_LINK,
+ ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK: import.meta.env
+ .VITE_ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK,
+ ONBOARDING_PROXY_INSTALL_LINK: import.meta.env
+ .VITE_ONBOARDING_PROXY_INSTALL_LINK,
+ DMCA_EMAIL: import.meta.env.VITE_DMCA_EMAIL,
+ CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL,
+ NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
+ BACKEND_URL: import.meta.env.VITE_BACKEND_URL,
+ 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)
+function getKeyValue(key: keyof Config): string | undefined {
+ let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`];
+ if (
+ windowValue !== null &&
+ windowValue !== undefined &&
+ windowValue.length === 0
+ )
+ windowValue = undefined;
+ return env[key] ?? windowValue ?? undefined;
+}
+
+function getKey(key: keyof Config, defaultString?: string): string {
+ return getKeyValue(key)?.toString() ?? defaultString ?? "";
+}
+
+export function conf(): RuntimeConfig {
+ const dmcaEmail = getKey("DMCA_EMAIL");
+ const chromeExtension = getKey("ONBOARDING_CHROME_EXTENSION_INSTALL_LINK");
+ const firefoxExtension = getKey("ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK");
+ const proxyInstallLink = getKey("ONBOARDING_PROXY_INSTALL_LINK");
+ const turnstileKey = getKey("TURNSTILE_KEY");
+ return {
+ APP_VERSION,
+ GITHUB_LINK,
+ DONATION_LINK,
+ DISCORD_LINK,
+ DMCA_EMAIL: dmcaEmail.length > 0 ? dmcaEmail : null,
+ ONBOARDING_CHROME_EXTENSION_INSTALL_LINK:
+ chromeExtension.length > 0 ? chromeExtension : null,
+ ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK:
+ firefoxExtension.length > 0 ? firefoxExtension : null,
+ ONBOARDING_PROXY_INSTALL_LINK:
+ proxyInstallLink.length > 0 ? proxyInstallLink : null,
+ BACKEND_URL: getKey("BACKEND_URL", BACKEND_URL),
+ TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"),
+ PROXY_URLS: getKey("CORS_PROXY_URL")
+ .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(",")
+ .map((v) => v.trim())
+ .filter((v) => v.length > 0), // Should be comma-seperated and contain the media type and ID, formatted like so: movie-753342,movie-753342,movie-753342
+ CDN_REPLACEMENTS: getKey("CDN_REPLACEMENTS", "")
+ .split(",")
+ .map((v) =>
+ v
+ .split(":")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0),
+ )
+ .filter((v) => v.length === 2), // The format is :,:
+ };
+}
diff --git a/src/utils/setup/constants.ts b/src/utils/setup/constants.ts
new file mode 100644
index 00000000..935987e9
--- /dev/null
+++ b/src/utils/setup/constants.ts
@@ -0,0 +1,6 @@
+export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
+export const DISCORD_LINK = "https://movie-web.github.io/links/discord";
+export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
+export const DONATION_LINK = "https://ko-fi.com/movieweb";
+export const GA_ID = import.meta.env.VITE_GA_ID;
+export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
diff --git a/src/utils/setup/ga.ts b/src/utils/setup/ga.ts
new file mode 100644
index 00000000..9b900c0d
--- /dev/null
+++ b/src/utils/setup/ga.ts
@@ -0,0 +1,11 @@
+import ReactGA from "react-ga4";
+
+import { GA_ID } from "@/setup/constants";
+
+if (GA_ID) {
+ ReactGA.initialize([
+ {
+ trackingId: GA_ID,
+ },
+ ]);
+}
diff --git a/src/utils/setup/i18n.ts b/src/utils/setup/i18n.ts
new file mode 100644
index 00000000..4c99433e
--- /dev/null
+++ b/src/utils/setup/i18n.ts
@@ -0,0 +1,27 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+
+import { locales } from "@/assets/languages";
+import { getLocaleInfo } from "@/utils/language";
+
+// Languages
+const langCodes = Object.keys(locales);
+const resources = Object.fromEntries(
+ Object.entries(locales).map((entry) => [entry[0], { translation: entry[1] }]),
+);
+i18n.use(initReactI18next).init({
+ fallbackLng: "en",
+ resources,
+ interpolation: {
+ escapeValue: false, // not needed for react as it escapes by default
+ },
+});
+
+export const appLanguageOptions = langCodes.map((lang) => {
+ const langObj = getLocaleInfo(lang);
+ if (!langObj)
+ throw new Error(`Language with code ${lang} cannot be found in database`);
+ return langObj;
+});
+
+export default i18n;
diff --git a/src/utils/setup/pwa.ts b/src/utils/setup/pwa.ts
new file mode 100644
index 00000000..e7147ea9
--- /dev/null
+++ b/src/utils/setup/pwa.ts
@@ -0,0 +1,27 @@
+import { registerSW } from "virtual:pwa-register";
+
+const intervalMS = 60 * 60 * 1000;
+
+registerSW({
+ immediate: true,
+ onRegisteredSW(swUrl, r) {
+ if (!r) return;
+ setInterval(async () => {
+ if (!(!r.installing && navigator)) return;
+
+ if ("connection" in navigator && !navigator.onLine) return;
+
+ const resp = await fetch(swUrl, {
+ cache: "no-store",
+ headers: {
+ cache: "no-store",
+ "cache-control": "no-cache",
+ },
+ });
+
+ if (resp?.status === 200) {
+ await r.update();
+ }
+ }, intervalMS);
+ },
+});