Merge pull request #172 from movie-web/dev

version 3.0.4
This commit is contained in:
mrjvs 2023-02-24 23:22:48 +01:00 committed by GitHub
commit 224cdb6710
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 3542 additions and 149 deletions

View File

@ -43,6 +43,7 @@ module.exports = {
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
"no-restricted-syntax": "off",
"import/no-unresolved": ["error", { ignore: ["^virtual:"] }],
"react/jsx-props-no-spreading": "off",
"consistent-return": "off",
"no-continue": "off",

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ node_modules
# production
/dist
dev-dist
# misc
.DS_Store

View File

@ -1,5 +1,8 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"eslint.format.enable": true
"eslint.format.enable": true,
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View File

@ -6,16 +6,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Because watching movies legally is boring"
content="The place for your favourite movies & shows"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#E880C5" />
<meta name="msapplication-TileColor" content="#E880C5" />
<meta name="theme-color" content="#E880C5" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
<meta name="msapplication-TileColor" content="#120f1d" />
<meta name="theme-color" content="#120f1d" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -25,7 +24,7 @@
/>
<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" />

View File

@ -1,6 +1,6 @@
{
"name": "movie-web",
"version": "3.0.3",
"version": "3.0.4",
"private": true,
"homepage": "https://movie.squeezebox.dev",
"dependencies": {
@ -26,12 +26,14 @@
"react-router-dom": "^5.2.0",
"react-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5",
"react-use": "^17.4.0",
"srt-webvtt": "^2.0.0",
"unpacker": "^1.0.1"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"preview": "vite preview",
"lint": "eslint --ext .tsx,.ts src",
"lint:fix": "eslint --fix --ext .tsx,.ts src",
@ -76,6 +78,7 @@
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "7.29.4",
"eslint-plugin-react-hooks": "4.3.0",
"jsdom": "^21.1.0",
"postcss": "^8.4.20",
"prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.7",
@ -84,6 +87,9 @@
"typescript": "^4.6.4",
"vite": "^4.0.1",
"vite-plugin-checker": "^0.5.6",
"vite-plugin-package-version": "^1.0.2"
"vite-plugin-package-version": "^1.0.2",
"vite-plugin-pwa": "^0.14.4",
"vitest": "^0.28.5",
"workbox-window": "^6.5.4"
}
}

View File

@ -3,7 +3,7 @@
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
<TileColor>#120f1d</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

@ -3,5 +3,5 @@ window.__CONFIG__ = {
VITE_CORS_PROXY_URL: "",
VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3",
VITE_OMDB_API_KEY: "aa0937c0"
VITE_OMDB_API_KEY: "aa0937c0",
};

1
public/ping.txt Normal file
View File

@ -0,0 +1 @@
pong

View File

@ -1,20 +0,0 @@
{
"name": "movie-web",
"short_name": "movie-web",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#E880C5",
"background_color": "#16171D",
"display": "standalone",
"start_url": "/"
}

View 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();
});
}
});
}
});

View 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",
},
],
},
},
},
];

View File

@ -1,6 +1,15 @@
import { conf } from "@/setup/config";
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 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);
});
return baseFetch<T>(conf().BASE_PROXY_URL, {
return baseFetch<T>(getProxyUrl(), {
...ops,
baseURL: undefined,
params: {

View File

@ -4,7 +4,10 @@ import { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const flixHqBase = "https://api.consumet.org/movies/flixhq";
// const flixHqBase = "https://api.consumet.org/movies/flixhq";
// *** TEMPORARY FIX - use other instance
// SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326
const flixHqBase = "https://c.delusionz.xyz/movies/flixhq";
registerProvider({
id: "flixhq",

View File

@ -35,6 +35,7 @@ const format = {
registerProvider({
id: "gdriveplayer",
displayName: "gdriveplayer",
disabled: true,
rank: 69,
type: [MWMediaType.MOVIE],

View File

@ -18,6 +18,7 @@ registerProvider({
displayName: "NetFilm",
rank: 15,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
disabled: true, // https://github.com/lamhoang1256/netfilm/issues/25
async scrape({ media, episode, progress }) {
// search for relevant item

28
src/components/Banner.tsx Normal file
View File

@ -0,0 +1,28 @@
import { Icon, Icons } from "@/components/Icon";
import { useBanner } from "@/hooks/useBanner";
export function Banner(props: { children: React.ReactNode; type: "error" }) {
const [ref] = useBanner<HTMLDivElement>("internet");
const styles = {
error: "bg-[#C93957] text-white",
};
const icons = {
error: Icons.CIRCLE_EXCLAMATION,
};
return (
<div ref={ref}>
<div
className={[
styles[props.type],
"flex items-center justify-center p-1",
].join(" ")}
>
<div className="flex items-center space-x-3">
<Icon icon={icons[props.type]} />
<div>{props.children}</div>
</div>
</div>
</div>
);
}

View File

@ -34,6 +34,8 @@ export enum Icons {
CAPTIONS = "captions",
LINK = "link",
CASTING = "casting",
CIRCLE_EXCLAMATION = "circle_exclamation",
DOWNLOAD = "download",
}
export interface IconProps {
@ -74,7 +76,9 @@ const iconList: Record<Icons, string> = {
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`,
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
casting: "",
download: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
};
function ChromeCastButton() {

View File

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { conf } from "@/setup/config";
import { useBannerSize } from "@/hooks/useBanner";
import { BrandPill } from "./BrandPill";
export interface NavigationProps {
@ -11,8 +12,15 @@ export interface NavigationProps {
}
export function Navigation(props: NavigationProps) {
const bannerHeight = useBannerSize();
return (
<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 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
style={{
top: `${bannerHeight}px`,
}}
>
<div className="fixed left-0 right-0 flex items-center justify-between py-5 px-7">
<div
className={`${

61
src/hooks/useBanner.tsx Normal file
View File

@ -0,0 +1,61 @@
import {
ReactNode,
createContext,
useState,
useMemo,
Dispatch,
SetStateAction,
useEffect,
useContext,
} from "react";
import { useMeasure } from "react-use";
interface BannerInstance {
id: string;
height: number;
}
const BannerContext = createContext<
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>]
>(null as any);
export function BannerContextProvider(props: { children: ReactNode }) {
const [state, setState] = useState<BannerInstance[]>([]);
const memod = useMemo<
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>]
>(() => [state, setState], [state]);
return (
<BannerContext.Provider value={memod}>
{props.children}
</BannerContext.Provider>
);
}
export function useBanner<T extends Element>(id: string) {
const [ref, { height }] = useMeasure<T>();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, set] = useContext(BannerContext);
useEffect(() => {
set((v) => [...v, { id, height: 0 }]);
set((value) => {
const v = value.find((item) => item.id === id);
if (v) {
v.height = height;
}
return value;
});
return () => {
set((v) => v.filter((item) => item.id !== id));
};
}, [height, id, set]);
return [ref];
}
export function useBannerSize() {
const [val] = useContext(BannerContext);
return val.reduce((a, v) => a + v.height, 0);
}

41
src/hooks/usePing.ts Normal file
View File

@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from "react";
export function useIsOnline() {
const [online, setOnline] = useState<boolean | null>(true);
const ref = useRef<boolean>(true);
useEffect(() => {
let counter = 0;
let abort: null | AbortController = null;
const interval = setInterval(() => {
// if online try once every 10 iterations intead of every iteration
counter += 1;
if (ref.current) {
if (counter < 10) return;
}
counter = 0;
if (abort) abort.abort();
abort = new AbortController();
const signal = abort.signal;
fetch("/ping.txt", { signal })
.then(() => {
setOnline(true);
ref.current = true;
})
.catch((err) => {
if (err.name === "AbortError") return;
setOnline(false);
ref.current = false;
});
}, 5000);
return () => {
clearInterval(interval);
if (abort) abort.abort();
};
}, []);
return online;
}

View File

@ -3,6 +3,7 @@ import ReactDOM from "react-dom";
import { BrowserRouter, HashRouter } from "react-router-dom";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import { conf } from "@/setup/config";
import { registerSW } from "virtual:pwa-register";
import App from "@/setup/App";
import "@/setup/ga";
@ -16,9 +17,14 @@ import { initializeStores } from "./utils/storage";
const key =
(window as any)?.__CONFIG__?.VITE_KEY ?? import.meta.env.VITE_KEY ?? null;
if (key) {
(window as any).initMW(conf().BASE_PROXY_URL, key);
(window as any).initMW(conf().PROXY_URLS, key);
}
initializeChromecast();
registerSW({
onNeedRefresh() {
window.location.reload();
},
});
const LazyLoadedApp = React.lazy(async () => {
await initializeStores();

View File

@ -7,25 +7,52 @@ import { MediaView } from "@/views/media/MediaView";
import { SearchView } from "@/views/search/SearchView";
import { MWMediaType } from "@/backend/metadata/types";
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";
import { BannerContextProvider } from "@/hooks/useBanner";
import { Layout } from "@/setup/Layout";
function App() {
return (
<WatchedContextProvider>
<BookmarkContextProvider>
<Switch>
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<Route exact path="/search/:type/:query?" component={SearchView} />
<Route path="*" component={NotFoundPage} />
</Switch>
<BannerContextProvider>
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
{/* pages */}
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<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} />
</Switch>
</Layout>
</BannerContextProvider>
</BookmarkContextProvider>
</WatchedContextProvider>
);

27
src/setup/Layout.tsx Normal file
View File

@ -0,0 +1,27 @@
import { Banner } from "@/components/Banner";
import { useBannerSize } from "@/hooks/useBanner";
import { useIsOnline } from "@/hooks/usePing";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
export function Layout(props: { children: ReactNode }) {
const { t } = useTranslation();
const isOnline = useIsOnline();
const bannerSize = useBannerSize();
return (
<div>
<div className="fixed inset-x-0 z-[1000]">
{!isOnline ? <Banner type="error">{t("errors.offline")}</Banner> : null}
</div>
<div
style={{
paddingTop: `${bannerSize}px`,
}}
className="flex min-h-screen flex-col"
>
{props.children}
</div>
</div>
);
}

View File

@ -10,8 +10,14 @@ interface Config {
NORMAL_ROUTER: boolean;
}
export interface RuntimeConfig extends Config {
BASE_PROXY_URL: string;
export interface RuntimeConfig {
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> = {
@ -27,12 +33,13 @@ const env: Record<keyof Config, undefined | string> = {
const alerts = [] as string[];
// 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}`];
if (windowValue !== undefined && windowValue.length === 0)
windowValue = undefined;
const value = env[key] ?? windowValue ?? undefined;
if (value === undefined) {
if (defaultString) return defaultString;
if (!alerts.includes(key)) {
// eslint-disable-next-line no-alert
window.alert(`Misconfigured instance, missing key: ${key}`);
@ -51,8 +58,9 @@ export function conf(): RuntimeConfig {
DISCORD_LINK,
OMDB_API_KEY: getKey("OMDB_API_KEY"),
TMDB_API_KEY: getKey("TMDB_API_KEY"),
BASE_PROXY_URL: getKey("CORS_PROXY_URL"),
CORS_PROXY_URL: `${getKey("CORS_PROXY_URL")}/?destination=`,
NORMAL_ROUTER: (getKey("NORMAL_ROUTER") ?? "false") === "true",
PROXY_URLS: getKey("CORS_PROXY_URL")
.split(",")
.map((v) => v.trim()),
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
};
}

View File

@ -1,4 +1,4 @@
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
export const APP_VERSION = "3.0.3";
export const APP_VERSION = "3.0.4";
export const GA_ID = "G-44YVXRL61C";

View File

@ -60,7 +60,8 @@
"buttons": {
"episodes": "Episodes",
"source": "Source",
"captions": "Captions"
"captions": "Captions",
"download": "Download"
},
"popouts": {
"sources": "Sources",
@ -88,5 +89,8 @@
},
"casting": {
"casting": "Casting to device..."
},
"errors": {
"offline": "Check your internet connection"
}
}

View File

@ -0,0 +1,7 @@
export function normalizeTitle(title: string): string {
return title
.trim()
.toLowerCase()
.replace(/['":]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "_");
}

View File

@ -1,10 +1,4 @@
function normalizeTitle(title: string): string {
return title
.trim()
.toLowerCase()
.replace(/['":]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "_");
}
import { normalizeTitle } from "./normalizeTitle";
export function compareTitle(a: string, b: string): boolean {
return normalizeTitle(a) === normalizeTitle(b);

View File

@ -30,6 +30,7 @@ import { ReactNode, useCallback, useState } from "react";
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
import { DownloadAction } from "@/video/components/actions/DownloadAction";
type Props = VideoPlayerBaseProps;
@ -120,6 +121,7 @@ export function VideoPlayer(props: Props) {
<HeaderAction
showControls={isMobile}
onClick={props.onGoBack}
isFullScreen
/>
</Transition>
<Transition
@ -141,6 +143,7 @@ export function VideoPlayer(props: Props) {
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center">
<div />
<div className="flex items-center justify-center">
<DownloadAction />
<CaptionsSelectionAction />
<SeriesSelectionAction />
<SourceSelectionAction />
@ -157,6 +160,7 @@ export function VideoPlayer(props: Props) {
<div className="mx-2 h-6 w-px bg-white opacity-50" />
<ChromecastAction />
<AirplayAction />
<DownloadAction />
<CaptionsSelectionAction />
<FullscreenAction />
</>

View File

@ -0,0 +1,41 @@
import { Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useSource } from "@/video/state/logic/source";
import { MWStreamType } from "@/backend/helpers/streams";
import { normalizeTitle } from "@/utils/normalizeTitle";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
import { useMeta } from "@/video/state/logic/meta";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
interface Props {
className?: string;
}
export function DownloadAction(props: Props) {
const descriptor = useVideoPlayerDescriptor();
const sourceInterface = useSource(descriptor);
const { isMobile } = useIsMobile();
const { t } = useTranslation();
const meta = useMeta(descriptor);
const isHLS = sourceInterface.source?.type === MWStreamType.HLS;
const title = meta?.meta.meta.title;
return (
<a
href={isHLS ? undefined : sourceInterface.source?.url}
rel="noreferrer"
target="_blank"
download={title ? normalizeTitle(title) : undefined}
>
<VideoPlayerIconButton
className={props.className}
icon={Icons.DOWNLOAD}
disabled={isHLS}
text={isMobile ? (t("videoPlayer.buttons.download") as string) : ""}
/>
</a>
);
}

View File

@ -5,6 +5,7 @@ import { useMeta } from "@/video/state/logic/meta";
interface Props {
onClick?: () => void;
showControls?: boolean;
isFullScreen: boolean;
}
export function HeaderAction(props: Props) {

View File

@ -10,11 +10,13 @@ import { AirplayAction } from "@/video/components/actions/AirplayAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { useTranslation } from "react-i18next";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useBannerSize } from "@/hooks/useBanner";
interface VideoPlayerHeaderProps {
media?: MWMediaMeta;
onClick?: () => void;
showControls?: boolean;
isFullScreen?: boolean;
}
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
@ -25,9 +27,15 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
: false;
const showDivider = props.media && props.onClick;
const { t } = useTranslation();
const bannerHeight = useBannerSize();
return (
<div className="flex items-center">
<div
className="flex items-center"
style={{
paddingTop: props.isFullScreen ? `${bannerHeight}px` : undefined,
}}
>
<div className="flex min-w-0 flex-1 items-center">
<p className="flex items-center truncate">
{props.onClick ? (

View File

@ -10,6 +10,7 @@ export interface VideoPlayerIconButtonProps {
active?: boolean;
wide?: boolean;
noPadding?: boolean;
disabled?: boolean;
}
export const VideoPlayerIconButton = forwardRef<
@ -21,13 +22,21 @@ export const VideoPlayerIconButton = forwardRef<
<button
type="button"
onClick={props.onClick}
className="group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110"
className={[
"group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110",
props.disabled
? "pointer-events-none cursor-not-allowed opacity-50"
: "",
].join(" ")}
>
<div
className={[
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 transition-colors duration-100 group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100",
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 transition-colors duration-100",
props.active ? "!bg-denim-500 !bg-opacity-100" : "",
!props.noPadding ? (props.wide ? "py-2 px-4" : "p-2") : "",
!props.disabled
? "group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100"
: "",
].join(" ")}
>
<Icon icon={props.icon} className={props.iconSize ?? "text-2xl"} />

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -9,7 +9,7 @@ export function MediaFetchErrorView() {
const goBack = useGoBack();
return (
<div className="h-screen flex-1">
<div className="flex-1">
<Helmet>
<title>{t("media.errors.failedMeta")}</title>
</Helmet>

View File

@ -28,7 +28,7 @@ function MediaViewLoading(props: { onGoBack(): void }) {
const { t } = useTranslation();
return (
<div className="relative flex h-screen items-center justify-center">
<div className="relative flex flex-1 items-center justify-center">
<Helmet>
<title>{t("videoPlayer.loading")}</title>
</Helmet>
@ -62,7 +62,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
}, [stream, props]);
return (
<div className="relative flex h-screen items-center justify-center">
<div className="relative flex flex-1 items-center justify-center">
<Helmet>
<title>{props.meta.meta.title}</title>
</Helmet>

View File

@ -17,18 +17,18 @@ export function NotFoundWrapper(props: {
const goBack = useGoBack();
return (
<div className="h-screen flex-1">
<div className="relative flex flex-1 flex-col">
<Helmet>
<title>{t("notFound.genericTitle")}</title>
</Helmet>
{props.video ? (
<div className="fixed inset-x-0 top-0 py-6 px-8">
<div className="absolute inset-x-0 top-0 py-6 px-8">
<VideoPlayerHeader onClick={goBack} />
</div>
) : (
<Navigation />
)}
<div className="flex h-full flex-col items-center justify-center p-5 text-center">
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center">
{props.children}
</div>
</div>

View File

@ -7,6 +7,7 @@ import { SearchBarInput } from "@/components/SearchBar";
import { Title } from "@/components/text/Title";
import { useSearchQuery } from "@/hooks/useSearchQuery";
import { WideContainer } from "@/components/layout/WideContainer";
import { useBannerSize } from "@/hooks/useBanner";
import { Helmet } from "react-helmet";
import { SearchResultsPartial } from "./SearchResultsPartial";
@ -14,6 +15,7 @@ export function SearchView() {
const { t } = useTranslation();
const [search, setSearch, setSearchUnFocus] = useSearchQuery();
const [showBg, setShowBg] = useState(false);
const bannerSize = useBannerSize();
const stickStateChanged = useCallback(
({ status }: Sticky.Status) => setShowBg(status === Sticky.STATUS_FIXED),
@ -36,7 +38,11 @@ export function SearchView() {
<Title className="mx-auto max-w-xs">{t("search.title")}</Title>
</div>
<div className="relative z-30">
<Sticky enabled top={16} onStateChange={stickStateChanged}>
<Sticky
enabled
top={16 + bannerSize}
onStateChange={stickStateChanged}
>
<SearchBarInput
onChange={setSearch}
value={search}

View File

@ -1,25 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
},
"types": ["vite/client"]
},
"include": ["src"]
}
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
},
"types": ["vite/client", "vite-plugin-pwa/client"]
},
"include": ["src"]
}

View File

@ -1,12 +1,60 @@
import { defineConfig } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import loadVersion from "vite-plugin-package-version";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import path from "path";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
injectRegister: "inline",
workbox: {
globIgnores: ["**ping.txt**"],
},
includeAssets: [
"favicon.ico",
"apple-touch-icon.png",
"safari-pinned-tab.svg",
],
manifest: {
name: "movie-web",
short_name: "movie-web",
description: "The place for your favourite movies & shows",
theme_color: "#120f1d",
background_color: "#120f1d",
display: "standalone",
start_url: "/",
icons: [
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
}),
loadVersion(),
checker({
typescript: true, // check typescript build errors in dev server
@ -24,4 +72,8 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
test: {
environment: "jsdom",
},
});

2663
yarn.lock

File diff suppressed because it is too large Load Diff