mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-28 15:01:49 +01:00
Merge branch 'movie-web:dev' into dev
This commit is contained in:
commit
ac350f276c
@ -40,7 +40,7 @@ To run this project locally for contributing or testing, run the following comma
|
|||||||
git clone https://github.com/movie-web/movie-web
|
git clone https://github.com/movie-web/movie-web
|
||||||
cd movie-web
|
cd movie-web
|
||||||
yarn install
|
yarn install
|
||||||
yarn start
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
To build production files, simply run `yarn build`.
|
To build production files, simply run `yarn build`.
|
||||||
|
20
index.html
20
index.html
@ -1,21 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
|
||||||
<script
|
|
||||||
async
|
|
||||||
src="https://www.googletagmanager.com/gtag/js?id=G-44YVXRL61C"
|
|
||||||
></script>
|
|
||||||
<script>
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag() {
|
|
||||||
dataLayer.push(arguments);
|
|
||||||
}
|
|
||||||
gtag("js", new Date());
|
|
||||||
|
|
||||||
gtag("config", "G-44YVXRL61C");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
@ -40,7 +25,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<script src="config.js"></script>
|
<script src="config.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@3744edbc5f64a77985b6421ea5040e688663634b/out.js"></script>
|
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@8b821f445b83d51ef1b8f42c99b7346f6b47dce5/out.js"></script>
|
||||||
|
|
||||||
|
<!-- prevent darkreader extension from messing with our already dark site -->
|
||||||
|
<meta name="darkreader-lock" />
|
||||||
|
|
||||||
<title>movie-web</title>
|
<title>movie-web</title>
|
||||||
</head>
|
</head>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "movie-web",
|
"name": "movie-web",
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://movie.squeezebox.dev",
|
"homepage": "https://movie.squeezebox.dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -20,6 +20,7 @@
|
|||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
"react-ga4": "^2.0.0",
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-i18next": "^12.1.1",
|
"react-i18next": "^12.1.1",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
@ -31,7 +32,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"test": "vitest run",
|
||||||
"lint": "eslint --ext .tsx,.ts src",
|
"lint": "eslint --ext .tsx,.ts src",
|
||||||
"lint:fix": "eslint --fix --ext .tsx,.ts src",
|
"lint:fix": "eslint --fix --ext .tsx,.ts src",
|
||||||
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src"
|
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src"
|
||||||
@ -75,6 +76,7 @@
|
|||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "7.29.4",
|
"eslint-plugin-react": "7.29.4",
|
||||||
"eslint-plugin-react-hooks": "4.3.0",
|
"eslint-plugin-react-hooks": "4.3.0",
|
||||||
|
"jsdom": "^21.1.0",
|
||||||
"postcss": "^8.4.20",
|
"postcss": "^8.4.20",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||||
@ -83,6 +85,7 @@
|
|||||||
"typescript": "^4.6.4",
|
"typescript": "^4.6.4",
|
||||||
"vite": "^4.0.1",
|
"vite": "^4.0.1",
|
||||||
"vite-plugin-checker": "^0.5.6",
|
"vite-plugin-checker": "^0.5.6",
|
||||||
"vite-plugin-package-version": "^1.0.2"
|
"vite-plugin-package-version": "^1.0.2",
|
||||||
|
"vitest": "^0.28.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
51
src/__tests__/providers/providers.test.ts
Normal file
51
src/__tests__/providers/providers.test.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
import "@/backend";
|
||||||
|
import { getProviders } from "@/backend/helpers/register";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
import { runProvider } from "@/backend/helpers/run";
|
||||||
|
import { testData } from "@/__tests__/providers/testdata";
|
||||||
|
|
||||||
|
describe("providers", () => {
|
||||||
|
const providers = getProviders();
|
||||||
|
|
||||||
|
it("have at least one provider", ({ expect }) => {
|
||||||
|
expect(providers.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
describe(provider.displayName, () => {
|
||||||
|
it("must have at least one type", async ({ expect }) => {
|
||||||
|
expect(provider.type.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (provider.type.includes(MWMediaType.MOVIE)) {
|
||||||
|
it("must work with movies", async ({ expect }) => {
|
||||||
|
const movie = testData.find((v) => v.meta.type === MWMediaType.MOVIE);
|
||||||
|
if (!movie) throw new Error("no movie to test with");
|
||||||
|
const results = await runProvider(provider, {
|
||||||
|
media: movie,
|
||||||
|
progress() {},
|
||||||
|
type: movie.meta.type as any,
|
||||||
|
});
|
||||||
|
expect(results).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.type.includes(MWMediaType.SERIES)) {
|
||||||
|
it("must work with series", async ({ expect }) => {
|
||||||
|
const show = testData.find((v) => v.meta.type === MWMediaType.SERIES);
|
||||||
|
if (show?.meta.type !== MWMediaType.SERIES)
|
||||||
|
throw new Error("no show to test with");
|
||||||
|
const results = await runProvider(provider, {
|
||||||
|
media: show,
|
||||||
|
progress() {},
|
||||||
|
type: show.meta.type as MWMediaType.SERIES,
|
||||||
|
episode: show.meta.seasonData.episodes[0].id,
|
||||||
|
season: show.meta.seasons[0].id,
|
||||||
|
});
|
||||||
|
expect(results).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
45
src/__tests__/providers/testdata.ts
Normal file
45
src/__tests__/providers/testdata.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
|
||||||
|
export const testData: DetailedMeta[] = [
|
||||||
|
{
|
||||||
|
imdbId: "tt10954562",
|
||||||
|
tmdbId: "572716",
|
||||||
|
meta: {
|
||||||
|
id: "439596",
|
||||||
|
title: "Hamilton",
|
||||||
|
type: MWMediaType.MOVIE,
|
||||||
|
year: "2020",
|
||||||
|
seasons: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imdbId: "tt11126994",
|
||||||
|
tmdbId: "94605",
|
||||||
|
meta: {
|
||||||
|
id: "222333",
|
||||||
|
title: "Arcane",
|
||||||
|
type: MWMediaType.SERIES,
|
||||||
|
year: "2021",
|
||||||
|
seasons: [
|
||||||
|
{
|
||||||
|
id: "230301",
|
||||||
|
number: 1,
|
||||||
|
title: "Season 1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
seasonData: {
|
||||||
|
id: "230301",
|
||||||
|
number: 1,
|
||||||
|
title: "Season 1",
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
id: "4243445",
|
||||||
|
number: 1,
|
||||||
|
title: "Welcome to the Playground",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
@ -1,6 +1,15 @@
|
|||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
import { ofetch } from "ofetch";
|
import { ofetch } from "ofetch";
|
||||||
|
|
||||||
|
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
|
||||||
|
|
||||||
|
// round robins all proxy urls
|
||||||
|
function getProxyUrl(): string {
|
||||||
|
const url = conf().PROXY_URLS[proxyUrlIndex];
|
||||||
|
proxyUrlIndex = (proxyUrlIndex + 1) % conf().PROXY_URLS.length;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
type P<T> = Parameters<typeof ofetch<T>>;
|
type P<T> = Parameters<typeof ofetch<T>>;
|
||||||
type R<T> = ReturnType<typeof ofetch<T>>;
|
type R<T> = ReturnType<typeof ofetch<T>>;
|
||||||
|
|
||||||
@ -41,7 +50,7 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
|||||||
parsedUrl.searchParams.set(k, v);
|
parsedUrl.searchParams.set(k, v);
|
||||||
});
|
});
|
||||||
|
|
||||||
return baseFetch<T>(conf().BASE_PROXY_URL, {
|
return baseFetch<T>(getProxyUrl(), {
|
||||||
...ops,
|
...ops,
|
||||||
baseURL: undefined,
|
baseURL: undefined,
|
||||||
params: {
|
params: {
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
export function BrandPill(props: { clickable?: boolean }) {
|
export function BrandPill(props: {
|
||||||
|
clickable?: boolean;
|
||||||
|
hideTextOnMobile?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -13,7 +16,14 @@ export function BrandPill(props: { clickable?: boolean }) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
||||||
<span className="font-semibold text-white">{t("global.name")}</span>
|
<span
|
||||||
|
className={[
|
||||||
|
"font-semibold text-white",
|
||||||
|
props.hideTextOnMobile ? "hidden sm:block" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{t("global.name")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,8 @@ export interface NavigationProps {
|
|||||||
|
|
||||||
export function Navigation(props: NavigationProps) {
|
export function Navigation(props: NavigationProps) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed left-0 right-0 top-0 z-10 flex min-h-[88px] items-center justify-between py-5 px-7">
|
<div className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent">
|
||||||
|
<div className="fixed left-0 right-0 flex items-center justify-between py-5 px-7">
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
props.bg ? "opacity-100" : "opacity-0"
|
props.bg ? "opacity-100" : "opacity-0"
|
||||||
@ -51,5 +52,6 @@ export function Navigation(props: NavigationProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,8 @@ interface SectionHeadingProps {
|
|||||||
|
|
||||||
export function SectionHeading(props: SectionHeadingProps) {
|
export function SectionHeading(props: SectionHeadingProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`mt-12 ${props.className}`}>
|
<div className={props.className}>
|
||||||
<div className="mb-4 flex items-end">
|
<div className="mb-5 flex items-center">
|
||||||
<p className="flex flex-1 items-center font-bold uppercase text-denim-700">
|
<p className="flex flex-1 items-center font-bold uppercase text-denim-700">
|
||||||
{props.icon ? (
|
{props.icon ? (
|
||||||
<span className="mr-2 text-xl">
|
<span className="mr-2 text-xl">
|
||||||
|
@ -45,14 +45,27 @@ function MediaCardContent({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100 group-hover:rounded-lg"
|
className={[
|
||||||
|
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
|
||||||
|
closable ? "" : "group-hover:rounded-lg",
|
||||||
|
].join(" ")}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{series ? (
|
{series ? (
|
||||||
<div className="absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors group-hover:bg-denim-500">
|
<div
|
||||||
<p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white">
|
className={[
|
||||||
|
"absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors",
|
||||||
|
closable ? "" : "group-hover:bg-denim-500",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={[
|
||||||
|
"text-center text-xs font-bold text-slate-400 transition-colors",
|
||||||
|
closable ? "" : "group-hover:text-white",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
{t("seasons.seasonAndEpisode", {
|
{t("seasons.seasonAndEpisode", {
|
||||||
season: series.season,
|
season: series.season,
|
||||||
episode: series.episode,
|
episode: series.episode,
|
||||||
@ -125,5 +138,9 @@ export function MediaCard(props: MediaCardProps) {
|
|||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
if (!props.linkable) return <span>{content}</span>;
|
if (!props.linkable) return <span>{content}</span>;
|
||||||
return <Link to={link}>{content}</Link>;
|
return (
|
||||||
|
<Link to={link} className={props.closable ? "hover:cursor-default" : ""}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export function useIsMobile() {
|
export function useIsMobile(horizontal?: boolean) {
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const isMobileCurrent = useRef<boolean | null>(false);
|
const isMobileCurrent = useRef<boolean | null>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onResize() {
|
function onResize() {
|
||||||
const value = window.innerWidth < 1024;
|
const value = horizontal
|
||||||
|
? window.innerHeight < 600
|
||||||
|
: window.innerWidth < 1024;
|
||||||
const isChanged = isMobileCurrent.current !== value;
|
const isChanged = isMobileCurrent.current !== value;
|
||||||
if (!isChanged) return;
|
if (!isChanged) return;
|
||||||
|
|
||||||
@ -20,7 +22,7 @@ export function useIsMobile() {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", onResize);
|
window.removeEventListener("resize", onResize);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [horizontal]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isMobile,
|
isMobile,
|
||||||
|
@ -5,6 +5,7 @@ import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
|||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
|
|
||||||
import App from "@/setup/App";
|
import App from "@/setup/App";
|
||||||
|
import "@/setup/ga";
|
||||||
import "@/setup/i18n";
|
import "@/setup/i18n";
|
||||||
import "@/setup/index.css";
|
import "@/setup/index.css";
|
||||||
import "@/backend";
|
import "@/backend";
|
||||||
@ -15,7 +16,7 @@ import { initializeStores } from "./utils/storage";
|
|||||||
const key =
|
const key =
|
||||||
(window as any)?.__CONFIG__?.VITE_KEY ?? import.meta.env.VITE_KEY ?? null;
|
(window as any)?.__CONFIG__?.VITE_KEY ?? import.meta.env.VITE_KEY ?? null;
|
||||||
if (key) {
|
if (key) {
|
||||||
(window as any).initMW(conf().BASE_PROXY_URL, key);
|
(window as any).initMW(conf().PROXY_URLS, key);
|
||||||
}
|
}
|
||||||
initializeChromecast();
|
initializeChromecast();
|
||||||
|
|
||||||
|
@ -7,16 +7,23 @@ import { MediaView } from "@/views/media/MediaView";
|
|||||||
import { SearchView } from "@/views/search/SearchView";
|
import { SearchView } from "@/views/search/SearchView";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
import { V2MigrationView } from "@/views/other/v2Migration";
|
import { V2MigrationView } from "@/views/other/v2Migration";
|
||||||
|
import { DeveloperView } from "@/views/developer/DeveloperView";
|
||||||
|
import { VideoTesterView } from "@/views/developer/VideoTesterView";
|
||||||
|
import { ProviderTesterView } from "@/views/developer/ProviderTesterView";
|
||||||
|
import { EmbedTesterView } from "@/views/developer/EmbedTesterView";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<WatchedContextProvider>
|
<WatchedContextProvider>
|
||||||
<BookmarkContextProvider>
|
<BookmarkContextProvider>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
{/* functional routes */}
|
||||||
<Route exact path="/v2-migration" component={V2MigrationView} />
|
<Route exact path="/v2-migration" component={V2MigrationView} />
|
||||||
<Route exact path="/">
|
<Route exact path="/">
|
||||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* pages */}
|
||||||
<Route exact path="/media/:media" component={MediaView} />
|
<Route exact path="/media/:media" component={MediaView} />
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
@ -24,6 +31,12 @@ function App() {
|
|||||||
component={MediaView}
|
component={MediaView}
|
||||||
/>
|
/>
|
||||||
<Route exact path="/search/:type/:query?" component={SearchView} />
|
<Route exact path="/search/:type/:query?" component={SearchView} />
|
||||||
|
|
||||||
|
{/* other */}
|
||||||
|
<Route exact path="/dev" component={DeveloperView} />
|
||||||
|
<Route exact path="/dev/video" component={VideoTesterView} />
|
||||||
|
<Route exact path="/dev/providers" component={ProviderTesterView} />
|
||||||
|
<Route exact path="/dev/embeds" component={EmbedTesterView} />
|
||||||
<Route path="*" component={NotFoundPage} />
|
<Route path="*" component={NotFoundPage} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</BookmarkContextProvider>
|
</BookmarkContextProvider>
|
||||||
|
@ -10,8 +10,14 @@ interface Config {
|
|||||||
NORMAL_ROUTER: boolean;
|
NORMAL_ROUTER: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeConfig extends Config {
|
export interface RuntimeConfig {
|
||||||
BASE_PROXY_URL: string;
|
APP_VERSION: string;
|
||||||
|
GITHUB_LINK: string;
|
||||||
|
DISCORD_LINK: string;
|
||||||
|
OMDB_API_KEY: string;
|
||||||
|
TMDB_API_KEY: string;
|
||||||
|
NORMAL_ROUTER: boolean;
|
||||||
|
PROXY_URLS: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const env: Record<keyof Config, undefined | string> = {
|
const env: Record<keyof Config, undefined | string> = {
|
||||||
@ -27,12 +33,13 @@ const env: Record<keyof Config, undefined | string> = {
|
|||||||
const alerts = [] as string[];
|
const alerts = [] as string[];
|
||||||
|
|
||||||
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
|
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
|
||||||
function getKey(key: keyof Config): string {
|
function getKey(key: keyof Config, defaultString?: string): string {
|
||||||
let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`];
|
let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`];
|
||||||
if (windowValue !== undefined && windowValue.length === 0)
|
if (windowValue !== undefined && windowValue.length === 0)
|
||||||
windowValue = undefined;
|
windowValue = undefined;
|
||||||
const value = env[key] ?? windowValue ?? undefined;
|
const value = env[key] ?? windowValue ?? undefined;
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
|
if (defaultString) return defaultString;
|
||||||
if (!alerts.includes(key)) {
|
if (!alerts.includes(key)) {
|
||||||
// eslint-disable-next-line no-alert
|
// eslint-disable-next-line no-alert
|
||||||
window.alert(`Misconfigured instance, missing key: ${key}`);
|
window.alert(`Misconfigured instance, missing key: ${key}`);
|
||||||
@ -51,8 +58,9 @@ export function conf(): RuntimeConfig {
|
|||||||
DISCORD_LINK,
|
DISCORD_LINK,
|
||||||
OMDB_API_KEY: getKey("OMDB_API_KEY"),
|
OMDB_API_KEY: getKey("OMDB_API_KEY"),
|
||||||
TMDB_API_KEY: getKey("TMDB_API_KEY"),
|
TMDB_API_KEY: getKey("TMDB_API_KEY"),
|
||||||
BASE_PROXY_URL: getKey("CORS_PROXY_URL"),
|
PROXY_URLS: getKey("CORS_PROXY_URL")
|
||||||
CORS_PROXY_URL: `${getKey("CORS_PROXY_URL")}/?destination=`,
|
.split(",")
|
||||||
NORMAL_ROUTER: (getKey("NORMAL_ROUTER") ?? "false") === "true",
|
.map((v) => v.trim()),
|
||||||
|
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||||
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
|
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
|
||||||
export const APP_VERSION = "3.0.2";
|
export const APP_VERSION = "3.0.3";
|
||||||
|
export const GA_ID = "G-44YVXRL61C";
|
||||||
|
8
src/setup/ga.ts
Normal file
8
src/setup/ga.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import ReactGA from "react-ga4";
|
||||||
|
import { GA_ID } from "@/setup/constants";
|
||||||
|
|
||||||
|
ReactGA.initialize([
|
||||||
|
{
|
||||||
|
trackingId: GA_ID,
|
||||||
|
},
|
||||||
|
]);
|
@ -55,6 +55,7 @@
|
|||||||
"noVideos": "Whoops, couldn't find any videos for you",
|
"noVideos": "Whoops, couldn't find any videos for you",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"backToHome": "Back to home",
|
"backToHome": "Back to home",
|
||||||
|
"backToHomeShort": "Back",
|
||||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
||||||
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
|
|
||||||
interface VideoPlayerHeaderProps {
|
interface VideoPlayerHeaderProps {
|
||||||
media?: MWMediaMeta;
|
media?: MWMediaMeta;
|
||||||
@ -17,6 +18,7 @@ interface VideoPlayerHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||||
|
const { isMobile } = useIsMobile();
|
||||||
const { bookmarkStore, setItemBookmark } = useBookmarkContext();
|
const { bookmarkStore, setItemBookmark } = useBookmarkContext();
|
||||||
const isBookmarked = props.media
|
const isBookmarked = props.media
|
||||||
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media)
|
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media)
|
||||||
@ -26,24 +28,26 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex flex-1 items-center">
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
<p className="flex items-center">
|
<p className="flex items-center truncate">
|
||||||
{props.onClick ? (
|
{props.onClick ? (
|
||||||
<span
|
<span
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100"
|
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Icon className="mr-2" icon={Icons.ARROW_LEFT} />
|
<Icon className="mr-2" icon={Icons.ARROW_LEFT} />
|
||||||
|
{isMobile ? (
|
||||||
|
<span>{t("videoPlayer.backToHomeShort")}</span>
|
||||||
|
) : (
|
||||||
<span>{t("videoPlayer.backToHome")}</span>
|
<span>{t("videoPlayer.backToHome")}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{showDivider ? (
|
{showDivider ? (
|
||||||
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
|
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
|
||||||
) : null}
|
) : null}
|
||||||
{props.media ? (
|
{props.media ? (
|
||||||
<span className="flex items-center text-white">
|
<span className="truncate text-white">{props.media.title}</span>
|
||||||
<span>{props.media.title}</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
{props.media && (
|
{props.media && (
|
||||||
@ -64,7 +68,7 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
|||||||
<ChromecastAction />
|
<ChromecastAction />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<BrandPill />
|
<BrandPill hideTextOnMobile />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -40,7 +40,9 @@ export const VideoPlayerIconButton = forwardRef<
|
|||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<Icon icon={props.icon} className={props.iconSize ?? "text-2xl"} />
|
<Icon icon={props.icon} className={props.iconSize ?? "text-2xl"} />
|
||||||
|
<p className="hidden sm:block">
|
||||||
{props.text ? <span className="ml-2">{props.text}</span> : null}
|
{props.text ? <span className="ml-2">{props.text}</span> : null}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,6 +5,7 @@ import { SourceSelectionPopout } from "@/video/components/popouts/SourceSelectio
|
|||||||
import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelectionPopout";
|
import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelectionPopout";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import {
|
import {
|
||||||
useInterface,
|
useInterface,
|
||||||
VideoInterfaceEvent,
|
VideoInterfaceEvent,
|
||||||
@ -37,6 +38,8 @@ function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) {
|
|||||||
const [bottom, setBottom] = useState<number>(0);
|
const [bottom, setBottom] = useState<number>(0);
|
||||||
const [width, setWidth] = useState<number>(0);
|
const [width, setWidth] = useState<number>(0);
|
||||||
|
|
||||||
|
const { isMobile } = useIsMobile(true);
|
||||||
|
|
||||||
const calculateAndSetCoords = useCallback((rect: DOMRect, w: number) => {
|
const calculateAndSetCoords = useCallback((rect: DOMRect, w: number) => {
|
||||||
const buttonCenter = rect.left + rect.width / 2;
|
const buttonCenter = rect.left + rect.width / 2;
|
||||||
|
|
||||||
@ -57,7 +60,10 @@ function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="absolute z-10 grid h-[500px] w-80 grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200"
|
className={[
|
||||||
|
"absolute z-10 grid w-80 grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200",
|
||||||
|
isMobile ? "h-[230px]" : " h-[500px]",
|
||||||
|
].join(" ")}
|
||||||
style={{
|
style={{
|
||||||
right: `${right}px`,
|
right: `${right}px`,
|
||||||
bottom: `${bottom}px`,
|
bottom: `${bottom}px`,
|
||||||
|
@ -169,8 +169,6 @@ export function SourceSelectionPopout() {
|
|||||||
return entries;
|
return entries;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(embedsRes);
|
|
||||||
|
|
||||||
return embedsRes;
|
return embedsRes;
|
||||||
}, [scrapeResult?.embeds]);
|
}, [scrapeResult?.embeds]);
|
||||||
|
|
||||||
|
26
src/views/developer/DeveloperView.tsx
Normal file
26
src/views/developer/DeveloperView.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
|
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||||
|
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||||
|
import { Title } from "@/components/text/Title";
|
||||||
|
|
||||||
|
export function DeveloperView() {
|
||||||
|
return (
|
||||||
|
<div className="py-48">
|
||||||
|
<Navigation />
|
||||||
|
<ThinContainer classNames="flex flex-col space-y-4">
|
||||||
|
<Title className="mb-8">Developer tools</Title>
|
||||||
|
<ArrowLink
|
||||||
|
to="/dev/providers"
|
||||||
|
direction="right"
|
||||||
|
linkText="Provider tester"
|
||||||
|
/>
|
||||||
|
<ArrowLink
|
||||||
|
to="/dev/embeds"
|
||||||
|
direction="right"
|
||||||
|
linkText="Embed scraper tester"
|
||||||
|
/>
|
||||||
|
<ArrowLink to="/dev/video" direction="right" linkText="Video tester" />
|
||||||
|
</ThinContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
136
src/views/developer/EmbedTesterView.tsx
Normal file
136
src/views/developer/EmbedTesterView.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { MWEmbed, MWEmbedScraper, MWEmbedType } from "@/backend/helpers/embed";
|
||||||
|
import { getEmbeds } from "@/backend/helpers/register";
|
||||||
|
import { runEmbedScraper } from "@/backend/helpers/run";
|
||||||
|
import { MWStream } from "@/backend/helpers/streams";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
|
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||||
|
import { Title } from "@/components/text/Title";
|
||||||
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
|
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
interface MediaSelectorProps {
|
||||||
|
embedType: MWEmbedType;
|
||||||
|
onSelect: (meta: MWEmbed) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmbedScraperSelectorProps {
|
||||||
|
onSelect: (embedScraperId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaScraperProps {
|
||||||
|
embed: MWEmbed;
|
||||||
|
scraper: MWEmbedScraper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaSelector(props: MediaSelectorProps) {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
|
||||||
|
const select = useCallback(
|
||||||
|
(urlSt: string) => {
|
||||||
|
props.onSelect({
|
||||||
|
type: props.embedType,
|
||||||
|
url: urlSt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[props]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<Title className="mb-8">Input embed url</Title>
|
||||||
|
<div className="mb-4 flex gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="embed url here..."
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => select(url)}>Run scraper</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaScraper(props: MediaScraperProps) {
|
||||||
|
const [results, setResults] = useState<MWStream | null>(null);
|
||||||
|
const [percentage, setPercentage] = useState(0);
|
||||||
|
|
||||||
|
const [scrape, loading, error] = useLoading(async (url: string) => {
|
||||||
|
const data = await runEmbedScraper(props.scraper, {
|
||||||
|
url,
|
||||||
|
progress(num) {
|
||||||
|
console.log(`SCRAPING AT ${num}%`);
|
||||||
|
setPercentage(num);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("got data", data);
|
||||||
|
setResults(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.embed) {
|
||||||
|
scrape(props.embed.url);
|
||||||
|
}
|
||||||
|
}, [props.embed, scrape]);
|
||||||
|
|
||||||
|
if (loading) return <p>Scraping... ({percentage}%)</p>;
|
||||||
|
if (error) return <p>Errored, check console</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title className="mb-8">Output data</Title>
|
||||||
|
<code>
|
||||||
|
<pre>{JSON.stringify(results, null, 2)}</pre>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmbedScraperSelector(props: EmbedScraperSelectorProps) {
|
||||||
|
const embedScrapers = getEmbeds();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<Title className="mb-8">Choose embed scraper</Title>
|
||||||
|
{embedScrapers.map((v) => (
|
||||||
|
<ArrowLink
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => props.onSelect(v.id)}
|
||||||
|
direction="right"
|
||||||
|
linkText={v.displayName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmbedTesterView() {
|
||||||
|
const [embed, setEmbed] = useState<MWEmbed | null>(null);
|
||||||
|
const [embedScraperId, setEmbedScraperId] = useState<string | null>(null);
|
||||||
|
const embedScraper = useMemo(
|
||||||
|
() => getEmbeds().find((v) => v.id === embedScraperId),
|
||||||
|
[embedScraperId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let content: ReactNode = null;
|
||||||
|
if (!embedScraperId || !embedScraper) {
|
||||||
|
content = <EmbedScraperSelector onSelect={(id) => setEmbedScraperId(id)} />;
|
||||||
|
} else if (!embed) {
|
||||||
|
content = (
|
||||||
|
<MediaSelector
|
||||||
|
embedType={embedScraper.for}
|
||||||
|
onSelect={(v) => setEmbed(v)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = <MediaScraper scraper={embedScraper} embed={embed} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-48">
|
||||||
|
<Navigation />
|
||||||
|
<div className="mx-8 overflow-x-auto">{content}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
118
src/views/developer/ProviderTesterView.tsx
Normal file
118
src/views/developer/ProviderTesterView.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
|
||||||
|
import { getProviders } from "@/backend/helpers/register";
|
||||||
|
import { runProvider } from "@/backend/helpers/run";
|
||||||
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
|
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||||
|
import { Title } from "@/components/text/Title";
|
||||||
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
|
import { testData } from "@/__tests__/providers/testdata";
|
||||||
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface MediaSelectorProps {
|
||||||
|
onSelect: (meta: DetailedMeta) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderSelectorProps {
|
||||||
|
onSelect: (providerId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaScraperProps {
|
||||||
|
media: DetailedMeta | null;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaSelector(props: MediaSelectorProps) {
|
||||||
|
const options: DetailedMeta[] = testData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<Title className="mb-8">Choose media</Title>
|
||||||
|
{options.map((v) => (
|
||||||
|
<ArrowLink
|
||||||
|
key={v.imdbId}
|
||||||
|
onClick={() => props.onSelect(v)}
|
||||||
|
direction="right"
|
||||||
|
linkText={`${v.meta.title} (${v.meta.type})`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaScraper(props: MediaScraperProps) {
|
||||||
|
const [results, setResults] = useState<MWProviderScrapeResult | null>(null);
|
||||||
|
const [percentage, setPercentage] = useState(0);
|
||||||
|
|
||||||
|
const [scrape, loading, error] = useLoading(async (media: DetailedMeta) => {
|
||||||
|
const provider = getProviders().find((v) => v.id === props.id);
|
||||||
|
if (!provider) throw new Error("provider not found");
|
||||||
|
const data = await runProvider(provider, {
|
||||||
|
progress(num) {
|
||||||
|
console.log(`SCRAPING AT ${num}%`);
|
||||||
|
setPercentage(num);
|
||||||
|
},
|
||||||
|
media,
|
||||||
|
type: media.meta.type as any,
|
||||||
|
});
|
||||||
|
console.log("got data", data);
|
||||||
|
setResults(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.media) {
|
||||||
|
scrape(props.media);
|
||||||
|
}
|
||||||
|
}, [props.media, scrape]);
|
||||||
|
|
||||||
|
if (loading) return <p>Scraping... ({percentage}%)</p>;
|
||||||
|
if (error) return <p>Errored, check console</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title className="mb-8">Output data</Title>
|
||||||
|
<code>
|
||||||
|
<pre>{JSON.stringify(results, null, 2)}</pre>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderSelector(props: ProviderSelectorProps) {
|
||||||
|
const providers = getProviders();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<Title className="mb-8">Choose provider</Title>
|
||||||
|
{providers.map((v) => (
|
||||||
|
<ArrowLink
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => props.onSelect(v.id)}
|
||||||
|
direction="right"
|
||||||
|
linkText={v.displayName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderTesterView() {
|
||||||
|
const [media, setMedia] = useState<DetailedMeta | null>(null);
|
||||||
|
const [providerId, setProviderId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
let content: ReactNode = null;
|
||||||
|
if (!providerId) {
|
||||||
|
content = <ProviderSelector onSelect={(id) => setProviderId(id)} />;
|
||||||
|
} else if (!media) {
|
||||||
|
content = <MediaSelector onSelect={(v) => setMedia(v)} />;
|
||||||
|
} else {
|
||||||
|
content = <MediaScraper id={providerId} media={media} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-48">
|
||||||
|
<Navigation />
|
||||||
|
<div className="mx-8 overflow-x-auto">{content}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
111
src/views/developer/VideoTesterView.tsx
Normal file
111
src/views/developer/VideoTesterView.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||||
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Dropdown } from "@/components/Dropdown";
|
||||||
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
|
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||||
|
import { MetaController } from "@/video/components/controllers/MetaController";
|
||||||
|
import { SourceController } from "@/video/components/controllers/SourceController";
|
||||||
|
import { VideoPlayer } from "@/video/components/VideoPlayer";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Helmet } from "react-helmet";
|
||||||
|
|
||||||
|
interface VideoData {
|
||||||
|
streamUrl: string;
|
||||||
|
type: MWStreamType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testData: VideoData = {
|
||||||
|
streamUrl:
|
||||||
|
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
||||||
|
type: MWStreamType.MP4,
|
||||||
|
};
|
||||||
|
const testMeta: DetailedMeta = {
|
||||||
|
imdbId: "",
|
||||||
|
tmdbId: "",
|
||||||
|
meta: {
|
||||||
|
id: "hello-world",
|
||||||
|
title: "Big Buck Bunny",
|
||||||
|
type: MWMediaType.MOVIE,
|
||||||
|
seasons: undefined,
|
||||||
|
year: "2000",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VideoTesterView() {
|
||||||
|
const [video, setVideo] = useState<VideoData | null>(null);
|
||||||
|
const [videoType, setVideoType] = useState<MWStreamType>(MWStreamType.MP4);
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
|
||||||
|
const playVideo = useCallback(
|
||||||
|
(streamUrl: string) => {
|
||||||
|
setVideo({
|
||||||
|
streamUrl,
|
||||||
|
type: videoType,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[videoType]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (video) {
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 left-0 h-[100dvh] w-screen">
|
||||||
|
<Helmet>
|
||||||
|
<html data-full="true" />
|
||||||
|
</Helmet>
|
||||||
|
<VideoPlayer includeSafeArea autoPlay onGoBack={() => setVideo(null)}>
|
||||||
|
<MetaController
|
||||||
|
data={{
|
||||||
|
captions: [],
|
||||||
|
meta: testMeta,
|
||||||
|
}}
|
||||||
|
linkedCaptions={[]}
|
||||||
|
/>
|
||||||
|
<SourceController
|
||||||
|
source={video.streamUrl}
|
||||||
|
type={MWStreamType.MP4}
|
||||||
|
quality={MWStreamQuality.Q720P}
|
||||||
|
/>
|
||||||
|
</VideoPlayer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-64">
|
||||||
|
<Navigation />
|
||||||
|
<ThinContainer classNames="flex items-start flex-col space-y-4">
|
||||||
|
<div className="w-48">
|
||||||
|
<Dropdown
|
||||||
|
options={[
|
||||||
|
{ id: MWStreamType.MP4, name: "Mp4" },
|
||||||
|
{ id: MWStreamType.HLS, name: "hls/m3u8" },
|
||||||
|
]}
|
||||||
|
selectedItem={{ id: videoType, name: videoType }}
|
||||||
|
setSelectedItem={(a) => setVideoType(a.id as MWStreamType)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 flex gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="stream url here..."
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => playVideo(url)}>Play video</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
setVideo({
|
||||||
|
streamUrl: testData.streamUrl,
|
||||||
|
type: testData.type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Play default video
|
||||||
|
</Button>
|
||||||
|
</ThinContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -32,7 +32,7 @@ function MediaViewLoading(props: { onGoBack(): void }) {
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("videoPlayer.loading")}</title>
|
<title>{t("videoPlayer.loading")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<div className="absolute inset-x-0 top-0 p-6">
|
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
||||||
<VideoPlayerHeader onClick={props.onGoBack} />
|
<VideoPlayerHeader onClick={props.onGoBack} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
|
@ -172,7 +172,7 @@ function NewDomainModal() {
|
|||||||
|
|
||||||
export function HomeView() {
|
export function HomeView() {
|
||||||
return (
|
return (
|
||||||
<div className="mb-16 mt-32">
|
<div className="mb-16">
|
||||||
<EmbedMigration />
|
<EmbedMigration />
|
||||||
<NewDomainModal />
|
<NewDomainModal />
|
||||||
<Bookmarks />
|
<Bookmarks />
|
||||||
|
@ -22,7 +22,7 @@ export function SearchView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative z-10 mb-24">
|
<div className="relative z-10 mb-16 sm:mb-24">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("global.name")}</title>
|
<title>{t("global.name")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
@ -32,10 +32,10 @@ export function SearchView() {
|
|||||||
<div className="absolute left-0 bottom-0 right-0 flex h-0 justify-center">
|
<div className="absolute left-0 bottom-0 right-0 flex h-0 justify-center">
|
||||||
<div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" />
|
<div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-20">
|
<div className="relative z-10 mb-16">
|
||||||
<div className="mb-16">
|
|
||||||
<Title className="mx-auto max-w-xs">{t("search.title")}</Title>
|
<Title className="mx-auto max-w-xs">{t("search.title")}</Title>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative z-30">
|
||||||
<Sticky enabled top={16} onStateChange={stickStateChanged}>
|
<Sticky enabled top={16} onStateChange={stickStateChanged}>
|
||||||
<SearchBarInput
|
<SearchBarInput
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import loadVersion from "vite-plugin-package-version";
|
import loadVersion from "vite-plugin-package-version";
|
||||||
import checker from "vite-plugin-checker";
|
import checker from "vite-plugin-checker";
|
||||||
@ -24,4 +24,8 @@ export default defineConfig({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user