mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-29 15:06:10 +00:00
commit
0f735f49d9
7
.editorconfig
Normal file
7
.editorconfig
Normal file
|
@ -0,0 +1,7 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_size = 2
|
||||
indent_style = space
|
22
.eslintrc.js
22
.eslintrc.js
|
@ -7,27 +7,28 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
|
|||
);
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true
|
||||
},
|
||||
extends: [
|
||||
"airbnb",
|
||||
"airbnb/hooks",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
"prettier",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {}
|
||||
}
|
||||
},
|
||||
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: "./"
|
||||
},
|
||||
plugins: ["@typescript-eslint", "import"],
|
||||
env: {
|
||||
browser: true
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {}
|
||||
}
|
||||
},
|
||||
plugins: ["@typescript-eslint", "import"],
|
||||
rules: {
|
||||
"react/jsx-uses-react": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
|
@ -47,6 +48,9 @@ module.exports = {
|
|||
"no-continue": "off",
|
||||
"no-eval": "off",
|
||||
"no-await-in-loop": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{ extensions: [".js", ".tsx", ".jsx"] }
|
||||
|
|
24
.github/workflows/deploying.yml
vendored
24
.github/workflows/deploying.yml
vendored
|
@ -31,30 +31,6 @@ jobs:
|
|||
name: production-files
|
||||
path: ./dist
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: production-files
|
||||
path: ./dist
|
||||
|
||||
- name: Insert config
|
||||
env:
|
||||
DEPLOY_CONFIG: ${{ secrets.DEPLOY_CONFIG }}
|
||||
run: echo "$DEPLOY_CONFIG" > ./dist/config.js
|
||||
|
||||
- name: Deploy to gh-pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./dist
|
||||
cname: movie.squeezebox.dev
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: build
|
||||
|
|
2
.github/workflows/linting_testing.yml
vendored
2
.github/workflows/linting_testing.yml
vendored
|
@ -5,7 +5,7 @@ on:
|
|||
branches:
|
||||
- master
|
||||
- dev
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
|
|
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
|
@ -1,7 +1,5 @@
|
|||
{
|
||||
"files.eol": "\n",
|
||||
"editor.detectIndentation": false,
|
||||
"editor.tabSize": 2,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
}
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"eslint.format.enable": true
|
||||
}
|
||||
|
|
18
README.md
18
README.md
|
@ -1,10 +1,10 @@
|
|||
<h1>movie-web</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/JamesHawkinss/movie-web/deploying.yml?branch=master&style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/JamesHawkinss/movie-web?style=flat-square"></a><br/>
|
||||
<a href="https://github.com/movie-web/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/movie-web/movie-web/deploying.yml?branch=master&style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/movie-web/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/movie-web/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/movie-web/movie-web?style=flat-square"></a><br/>
|
||||
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
</p>
|
||||
|
||||
|
@ -37,7 +37,7 @@ To run this project locally for contributing or testing, run the following comma
|
|||
<h5><b>note: must use yarn to install packages and run NodeJS 16</b></h5>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/JamesHawkinss/movie-web
|
||||
git clone https://github.com/movie-web/movie-web
|
||||
cd movie-web
|
||||
yarn install
|
||||
yarn start
|
||||
|
@ -47,10 +47,10 @@ To build production files, simply run `yarn build`.
|
|||
|
||||
You'll need to deploy a cloudflare service worker as well. Check the [selfhosting guide](https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md) on how to run the service worker. Afterwards you can make a `.env` file and put in the URL. (see `example.env` for an example)
|
||||
|
||||
<h2>Contributing - <a href="https://github.com/JamesHawkinss/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/JamesHawkinss/movie-web?style=flat-square"></a></h2>
|
||||
<h2>Contributing - <a href="https://github.com/movie-web/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/movie-web/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/movie-web/movie-web?style=flat-square"></a></h2>
|
||||
|
||||
Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
|
||||
Check out [this project's issues](https://github.com/movie-web/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
|
||||
|
||||
**All pull requests must be merged into the `dev` branch. it will then be deployed with the next version**
|
||||
|
||||
|
@ -58,7 +58,7 @@ Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/iss
|
|||
|
||||
This project would not be possible without our amazing contributors and the community.
|
||||
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/movie-web/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/movie-web/movie-web?style=flat-square"></a>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/JamesHawkinss.png?size=20" width="20"><span><a href="https://github.com/JamesHawkinss">@JamesHawkinss</a> for original concept.</span>
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Because watching movies legally is boring"
|
||||
|
@ -35,12 +35,13 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@d63f572f6f873bda166c2d7d3772c51d14e1c319/out.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@45bd2817788c84834cf508591f62dcb627bbb1a2/out.js"></script>
|
||||
|
||||
<title>movie-web</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
26
package.json
26
package.json
|
@ -4,19 +4,27 @@
|
|||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"crypto-js": "^4.1.1",
|
||||
"fscreen": "^1.2.0",
|
||||
"fuse.js": "^6.4.6",
|
||||
"hls.js": "^1.0.7",
|
||||
"i18next": "^22.4.5",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"i18next-http-backend": "^2.1.0",
|
||||
"json5": "^2.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"ofetch": "^1.0.0",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-i18next": "^12.1.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"unpacker": "^1.0.1"
|
||||
},
|
||||
|
@ -25,7 +33,7 @@
|
|||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .tsx,.ts src",
|
||||
"lint:strict": "eslint --ext .tsx,.ts --max-warnings 0 src",
|
||||
"lint:fix": "eslint --fix --ext .tsx,.ts src",
|
||||
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src"
|
||||
},
|
||||
"browserslist": {
|
||||
|
@ -41,22 +49,30 @@
|
|||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/chromecast-caf-sender": "^1.0.5",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/fscreen": "^1.0.1",
|
||||
"@types/lodash.throttle": "^4.1.7",
|
||||
"@types/node": "^17.0.15",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-router": "^5.1.18",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-stickynode": "^4.0.0",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.13.0",
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-import-resolver-typescript": "^2.5.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"postcss": "^8.4.20",
|
||||
|
@ -65,6 +81,8 @@
|
|||
"tailwind-scrollbar": "^2.0.1",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^4.0.1"
|
||||
"vite": "^4.0.1",
|
||||
"vite-plugin-checker": "^0.5.6",
|
||||
"vite-plugin-package-version": "^1.0.2"
|
||||
}
|
||||
}
|
||||
|
|
4
prettierrc.js
Normal file
4
prettierrc.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
trailingComma: "all",
|
||||
singleQuote: true
|
||||
};
|
1
public/_redirects
Normal file
1
public/_redirects
Normal file
|
@ -0,0 +1 @@
|
|||
/* /index.html 200
|
|
@ -1,50 +0,0 @@
|
|||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading": "Fetching your favourite shows...",
|
||||
"providersFailed": "{{fails}}/{{total}} providers failed!",
|
||||
"allResults": "That's all we have!",
|
||||
"noResults": "We couldn't find anything!",
|
||||
"allFailed": "All providers have failed!",
|
||||
"headingTitle": "Search results",
|
||||
"headingLink": "Back to home",
|
||||
"bookmarks": "Bookmarks",
|
||||
"continueWatching": "Continue Watching",
|
||||
"tagline": "Because watching legally is boring",
|
||||
"title": "What do you want to watch?",
|
||||
"placeholder": "What do you want to watch?"
|
||||
},
|
||||
"media": {
|
||||
"invalidUrl": "Your URL may be invalid",
|
||||
"arrowText": "Go back"
|
||||
},
|
||||
"seasons": {
|
||||
"season": "Season {{season}}",
|
||||
"failed": "Failed to get season data"
|
||||
},
|
||||
"notFound": {
|
||||
"backArrow": "Back to home",
|
||||
"media": {
|
||||
"title": "Couldn't find that media",
|
||||
"description": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL"
|
||||
},
|
||||
"provider": {
|
||||
"title": "This provider has been disabled",
|
||||
"description": "We had issues with the provider or it was too unstable to use, so we had to disable it."
|
||||
},
|
||||
"page": {
|
||||
"title": "Couldn't find that page",
|
||||
"description": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Movie",
|
||||
"series": "Series",
|
||||
"Search": "Search"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"text": "The app encountered an error and wasn't able to recover, please report it to the"
|
||||
}
|
||||
}
|
1
src/backend/embeds/.gitkeep
Normal file
1
src/backend/embeds/.gitkeep
Normal file
|
@ -0,0 +1 @@
|
|||
embed scrapers go here
|
19
src/backend/embeds/playm4u.ts
Normal file
19
src/backend/embeds/playm4u.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
|
||||
registerEmbedScraper({
|
||||
id: "playm4u",
|
||||
displayName: "playm4u",
|
||||
for: MWEmbedType.PLAYM4U,
|
||||
rank: 0,
|
||||
async getStream() {
|
||||
// throw new Error("Oh well 2")
|
||||
return {
|
||||
streamUrl: "",
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
captions: [],
|
||||
type: MWStreamType.MP4,
|
||||
};
|
||||
},
|
||||
});
|
65
src/backend/embeds/streamm4u.ts
Normal file
65
src/backend/embeds/streamm4u.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
MWStream,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
|
||||
const HOST = "streamm4u.club";
|
||||
const URL_BASE = `https://${HOST}`;
|
||||
const URL_API = `${URL_BASE}/api`;
|
||||
const URL_API_SOURCE = `${URL_API}/source`;
|
||||
|
||||
async function scrape(embed: string) {
|
||||
const sources: MWStream[] = [];
|
||||
|
||||
const embedID = embed.split("/").pop();
|
||||
|
||||
console.log(`${URL_API_SOURCE}/${embedID}`);
|
||||
const json = await proxiedFetch<any>(`${URL_API_SOURCE}/${embedID}`, {
|
||||
method: "POST",
|
||||
body: `r=&d=${HOST}`,
|
||||
});
|
||||
|
||||
if (json.success) {
|
||||
const streams = json.data;
|
||||
|
||||
for (const stream of streams) {
|
||||
sources.push({
|
||||
streamUrl: stream.file as string,
|
||||
quality: stream.label as MWStreamQuality,
|
||||
type: stream.type as MWStreamType,
|
||||
captions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
// TODO check out 403 / 404 on successfully returned video stream URLs
|
||||
registerEmbedScraper({
|
||||
id: "streamm4u",
|
||||
displayName: "streamm4u",
|
||||
for: MWEmbedType.STREAMM4U,
|
||||
rank: 100,
|
||||
async getStream({ progress, url }) {
|
||||
// const scrapingThreads = [];
|
||||
// const streams = [];
|
||||
|
||||
const sources = (await scrape(url)).sort(
|
||||
(a, b) =>
|
||||
Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", ""))
|
||||
);
|
||||
// const preferredSourceIndex = 0;
|
||||
const preferredSource = sources[0];
|
||||
|
||||
if (!preferredSource) throw new Error("No source found");
|
||||
|
||||
progress(100);
|
||||
|
||||
return preferredSource;
|
||||
},
|
||||
});
|
34
src/backend/helpers/captions.ts
Normal file
34
src/backend/helpers/captions.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
import toWebVTT from "srt-webvtt";
|
||||
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
if (caption.type === MWCaptionType.SRT) {
|
||||
let captionBlob: Blob;
|
||||
|
||||
if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
|
||||
return toWebVTT(captionBlob);
|
||||
}
|
||||
|
||||
if (caption.type === MWCaptionType.VTT) {
|
||||
if (caption.needsProxy) {
|
||||
const blob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
return caption.url;
|
||||
}
|
||||
|
||||
throw new Error("invalid type");
|
||||
}
|
27
src/backend/helpers/embed.ts
Normal file
27
src/backend/helpers/embed.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { MWStream } from "./streams";
|
||||
|
||||
export enum MWEmbedType {
|
||||
M4UFREE = "m4ufree",
|
||||
STREAMM4U = "streamm4u",
|
||||
PLAYM4U = "playm4u",
|
||||
}
|
||||
|
||||
export type MWEmbed = {
|
||||
type: MWEmbedType;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type MWEmbedContext = {
|
||||
progress(percentage: number): void;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type MWEmbedScraper = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
for: MWEmbedType;
|
||||
rank: number;
|
||||
disabled?: boolean;
|
||||
|
||||
getStream(ctx: MWEmbedContext): Promise<MWStream>;
|
||||
};
|
51
src/backend/helpers/fetch.ts
Normal file
51
src/backend/helpers/fetch.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { conf } from "@/setup/config";
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
type P<T> = Parameters<typeof ofetch<T>>;
|
||||
type R<T> = ReturnType<typeof ofetch<T>>;
|
||||
|
||||
const baseFetch = ofetch.create({
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
export function makeUrl(url: string, data: Record<string, string>) {
|
||||
let parsedUrl: string = url;
|
||||
Object.entries(data).forEach(([k, v]) => {
|
||||
parsedUrl = parsedUrl.replace(`{${k}}`, encodeURIComponent(v));
|
||||
});
|
||||
return parsedUrl;
|
||||
}
|
||||
|
||||
export function mwFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||
return baseFetch<T>(url, ops);
|
||||
}
|
||||
|
||||
export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||
let combinedUrl = ops?.baseURL ?? "";
|
||||
if (
|
||||
combinedUrl.length > 0 &&
|
||||
combinedUrl.endsWith("/") &&
|
||||
url.startsWith("/")
|
||||
)
|
||||
combinedUrl += url.slice(1);
|
||||
else if (
|
||||
combinedUrl.length > 0 &&
|
||||
!combinedUrl.endsWith("/") &&
|
||||
!url.startsWith("/")
|
||||
)
|
||||
combinedUrl += `/${url}`;
|
||||
else combinedUrl += url;
|
||||
|
||||
const parsedUrl = new URL(combinedUrl);
|
||||
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return baseFetch<T>(conf().BASE_PROXY_URL, {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
params: {
|
||||
destination: parsedUrl.toString(),
|
||||
},
|
||||
});
|
||||
}
|
36
src/backend/helpers/provider.ts
Normal file
36
src/backend/helpers/provider.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { DetailedMeta } from "../metadata/getmeta";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
import { MWEmbed } from "./embed";
|
||||
import { MWStream } from "./streams";
|
||||
|
||||
export type MWProviderScrapeResult = {
|
||||
stream?: MWStream;
|
||||
embeds: MWEmbed[];
|
||||
};
|
||||
|
||||
type MWProviderBase = {
|
||||
progress(percentage: number): void;
|
||||
media: DetailedMeta;
|
||||
};
|
||||
type MWProviderTypeSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode?: undefined;
|
||||
season?: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: string;
|
||||
season: string;
|
||||
};
|
||||
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;
|
||||
|
||||
export type MWProvider = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
rank: number;
|
||||
disabled?: boolean;
|
||||
type: MWMediaType[];
|
||||
|
||||
scrape(ctx: MWProviderContext): Promise<MWProviderScrapeResult>;
|
||||
};
|
72
src/backend/helpers/register.ts
Normal file
72
src/backend/helpers/register.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { MWEmbedScraper, MWEmbedType } from "./embed";
|
||||
import { MWProvider } from "./provider";
|
||||
|
||||
let providers: MWProvider[] = [];
|
||||
let embeds: MWEmbedScraper[] = [];
|
||||
|
||||
export function registerProvider(provider: MWProvider) {
|
||||
if (provider.disabled) return;
|
||||
providers.push(provider);
|
||||
}
|
||||
export function registerEmbedScraper(embed: MWEmbedScraper) {
|
||||
if (embed.disabled) return;
|
||||
embeds.push(embed);
|
||||
}
|
||||
|
||||
export function initializeScraperStore() {
|
||||
// sort by ranking
|
||||
providers = providers.sort((a, b) => b.rank - a.rank);
|
||||
embeds = embeds.sort((a, b) => b.rank - a.rank);
|
||||
|
||||
// check for invalid ranks
|
||||
let lastRank: null | number = null;
|
||||
providers.forEach((v) => {
|
||||
if (lastRank === null) {
|
||||
lastRank = v.rank;
|
||||
return;
|
||||
}
|
||||
if (lastRank === v.rank)
|
||||
throw new Error(`Duplicate rank number for provider ${v.id}`);
|
||||
lastRank = v.rank;
|
||||
});
|
||||
lastRank = null;
|
||||
providers.forEach((v) => {
|
||||
if (lastRank === null) {
|
||||
lastRank = v.rank;
|
||||
return;
|
||||
}
|
||||
if (lastRank === v.rank)
|
||||
throw new Error(`Duplicate rank number for embed scraper ${v.id}`);
|
||||
lastRank = v.rank;
|
||||
});
|
||||
|
||||
// check for duplicate ids
|
||||
const providerIds = providers.map((v) => v.id);
|
||||
if (
|
||||
providerIds.length > 0 &&
|
||||
new Set(providerIds).size !== providerIds.length
|
||||
)
|
||||
throw new Error("Duplicate IDS in providers");
|
||||
const embedIds = embeds.map((v) => v.id);
|
||||
if (embedIds.length > 0 && new Set(embedIds).size !== embedIds.length)
|
||||
throw new Error("Duplicate IDS in embed scrapers");
|
||||
|
||||
// check for duplicate embed types
|
||||
const embedTypes = embeds.map((v) => v.for);
|
||||
if (embedTypes.length > 0 && new Set(embedTypes).size !== embedTypes.length)
|
||||
throw new Error("Duplicate types in embed scrapers");
|
||||
}
|
||||
|
||||
export function getProviders(): MWProvider[] {
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function getEmbeds(): MWEmbedScraper[] {
|
||||
return embeds;
|
||||
}
|
||||
|
||||
export function getEmbedScraperByType(
|
||||
type: MWEmbedType
|
||||
): MWEmbedScraper | null {
|
||||
return getEmbeds().find((v) => v.for === type) ?? null;
|
||||
}
|
52
src/backend/helpers/run.ts
Normal file
52
src/backend/helpers/run.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { MWEmbed, MWEmbedContext, MWEmbedScraper } from "./embed";
|
||||
import {
|
||||
MWProvider,
|
||||
MWProviderContext,
|
||||
MWProviderScrapeResult,
|
||||
} from "./provider";
|
||||
import { getEmbedScraperByType } from "./register";
|
||||
import { MWStream } from "./streams";
|
||||
|
||||
function sortProviderResult(
|
||||
ctx: MWProviderScrapeResult
|
||||
): MWProviderScrapeResult {
|
||||
ctx.embeds = ctx.embeds
|
||||
.map<[MWEmbed, MWEmbedScraper | null]>((v) => [
|
||||
v,
|
||||
v.type ? getEmbedScraperByType(v.type) : null,
|
||||
])
|
||||
.sort(([, a], [, b]) => (b?.rank ?? 0) - (a?.rank ?? 0))
|
||||
.map((v) => v[0]);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function runProvider(
|
||||
provider: MWProvider,
|
||||
ctx: MWProviderContext
|
||||
): Promise<MWProviderScrapeResult> {
|
||||
try {
|
||||
const data = await provider.scrape(ctx);
|
||||
return sortProviderResult(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to run provider", err, {
|
||||
id: provider.id,
|
||||
ctx: { ...ctx },
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runEmbedScraper(
|
||||
scraper: MWEmbedScraper,
|
||||
ctx: MWEmbedContext
|
||||
): Promise<MWStream> {
|
||||
try {
|
||||
return await scraper.getStream(ctx);
|
||||
} catch (err) {
|
||||
console.error("Failed to run embed scraper", {
|
||||
id: scraper.id,
|
||||
ctx: { ...ctx },
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
166
src/backend/helpers/scrape.ts
Normal file
166
src/backend/helpers/scrape.ts
Normal file
|
@ -0,0 +1,166 @@
|
|||
import { MWProviderContext, MWProviderScrapeResult } from "./provider";
|
||||
import { getEmbedScraperByType, getProviders } from "./register";
|
||||
import { runEmbedScraper, runProvider } from "./run";
|
||||
import { MWStream } from "./streams";
|
||||
import { DetailedMeta } from "../metadata/getmeta";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
interface MWProgressData {
|
||||
type: "embed" | "provider";
|
||||
id: string;
|
||||
eventId: string;
|
||||
percentage: number;
|
||||
errored: boolean;
|
||||
}
|
||||
interface MWNextData {
|
||||
id: string;
|
||||
eventId: string;
|
||||
type: "embed" | "provider";
|
||||
}
|
||||
|
||||
type MWProviderRunContextBase = {
|
||||
media: DetailedMeta;
|
||||
onProgress?: (data: MWProgressData) => void;
|
||||
onNext?: (data: MWNextData) => void;
|
||||
};
|
||||
type MWProviderRunContextTypeSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode: undefined;
|
||||
season: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: string;
|
||||
season: string;
|
||||
};
|
||||
|
||||
export type MWProviderRunContext = MWProviderRunContextBase &
|
||||
MWProviderRunContextTypeSpecific;
|
||||
|
||||
async function findBestEmbedStream(
|
||||
result: MWProviderScrapeResult,
|
||||
providerId: string,
|
||||
ctx: MWProviderRunContext
|
||||
): Promise<MWStream | null> {
|
||||
if (result.stream) return result.stream;
|
||||
|
||||
let embedNum = 0;
|
||||
for (const embed of result.embeds) {
|
||||
embedNum += 1;
|
||||
if (!embed.type) continue;
|
||||
const scraper = getEmbedScraperByType(embed.type);
|
||||
if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`);
|
||||
|
||||
const eventId = [providerId, scraper.id, embedNum].join("|");
|
||||
|
||||
ctx.onNext?.({ id: scraper.id, type: "embed", eventId });
|
||||
|
||||
let stream: MWStream;
|
||||
try {
|
||||
stream = await runEmbedScraper(scraper, {
|
||||
url: embed.url,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: num,
|
||||
type: "embed",
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
ctx.onProgress?.({
|
||||
errored: true,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: 100,
|
||||
type: "embed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: 100,
|
||||
type: "embed",
|
||||
});
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findBestStream(
|
||||
ctx: MWProviderRunContext
|
||||
): Promise<MWStream | null> {
|
||||
const providers = getProviders();
|
||||
|
||||
for (const provider of providers) {
|
||||
const eventId = provider.id;
|
||||
ctx.onNext?.({ id: provider.id, type: "provider", eventId });
|
||||
let result: MWProviderScrapeResult;
|
||||
try {
|
||||
let context: MWProviderContext;
|
||||
if (ctx.type === MWMediaType.SERIES) {
|
||||
context = {
|
||||
media: ctx.media,
|
||||
type: ctx.type,
|
||||
episode: ctx.episode,
|
||||
season: ctx.season,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
percentage: num,
|
||||
eventId,
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
},
|
||||
};
|
||||
} else {
|
||||
context = {
|
||||
media: ctx.media,
|
||||
type: ctx.type,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
percentage: num,
|
||||
eventId,
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
result = await runProvider(provider, context);
|
||||
} catch (err) {
|
||||
ctx.onProgress?.({
|
||||
percentage: 100,
|
||||
errored: true,
|
||||
eventId,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
eventId,
|
||||
percentage: 100,
|
||||
type: "provider",
|
||||
});
|
||||
|
||||
const stream = await findBestEmbedStream(result, provider.id, ctx);
|
||||
if (!stream) continue;
|
||||
return stream;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
31
src/backend/helpers/streams.ts
Normal file
31
src/backend/helpers/streams.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
export enum MWStreamType {
|
||||
MP4 = "mp4",
|
||||
HLS = "hls",
|
||||
}
|
||||
|
||||
export enum MWCaptionType {
|
||||
VTT = "vtt",
|
||||
SRT = "srt",
|
||||
}
|
||||
|
||||
export enum MWStreamQuality {
|
||||
Q360P = "360p",
|
||||
Q480P = "480p",
|
||||
Q720P = "720p",
|
||||
Q1080P = "1080p",
|
||||
QUNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
export type MWCaption = {
|
||||
needsProxy?: boolean;
|
||||
url: string;
|
||||
type: MWCaptionType;
|
||||
langIso: string;
|
||||
};
|
||||
|
||||
export type MWStream = {
|
||||
streamUrl: string;
|
||||
type: MWStreamType;
|
||||
quality: MWStreamQuality;
|
||||
captions: MWCaption[];
|
||||
};
|
14
src/backend/index.ts
Normal file
14
src/backend/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { initializeScraperStore } from "./helpers/register";
|
||||
|
||||
// providers
|
||||
import "./providers/gdriveplayer";
|
||||
import "./providers/flixhq";
|
||||
import "./providers/superstream";
|
||||
import "./providers/netfilm";
|
||||
import "./providers/m4ufree";
|
||||
|
||||
// embeds
|
||||
import "./embeds/streamm4u";
|
||||
import "./embeds/playm4u";
|
||||
|
||||
initializeScraperStore();
|
80
src/backend/metadata/getmeta.ts
Normal file
80
src/backend/metadata/getmeta.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { FetchError } from "ofetch";
|
||||
import { makeUrl, proxiedFetch } from "../helpers/fetch";
|
||||
import {
|
||||
formatJWMeta,
|
||||
JWMediaResult,
|
||||
JWSeasonMetaResult,
|
||||
JW_API_BASE,
|
||||
mediaTypeToJW,
|
||||
} from "./justwatch";
|
||||
import { MWMediaMeta, MWMediaType } from "./types";
|
||||
|
||||
type JWExternalIdType =
|
||||
| "eidr"
|
||||
| "imdb_latest"
|
||||
| "imdb"
|
||||
| "tmdb_latest"
|
||||
| "tmdb"
|
||||
| "tms";
|
||||
|
||||
interface JWExternalId {
|
||||
provider: JWExternalIdType;
|
||||
external_id: string;
|
||||
}
|
||||
|
||||
interface JWDetailedMeta extends JWMediaResult {
|
||||
external_ids: JWExternalId[];
|
||||
}
|
||||
|
||||
export interface DetailedMeta {
|
||||
meta: MWMediaMeta;
|
||||
tmdbId: string;
|
||||
imdbId: string;
|
||||
}
|
||||
|
||||
export async function getMetaFromId(
|
||||
type: MWMediaType,
|
||||
id: string,
|
||||
seasonId?: string
|
||||
): Promise<DetailedMeta | null> {
|
||||
const queryType = mediaTypeToJW(type);
|
||||
|
||||
let data: JWDetailedMeta;
|
||||
try {
|
||||
const url = makeUrl("/content/titles/{type}/{id}/locale/en_US", {
|
||||
type: queryType,
|
||||
id,
|
||||
});
|
||||
data = await proxiedFetch<JWDetailedMeta>(url, { baseURL: JW_API_BASE });
|
||||
} catch (err) {
|
||||
if (err instanceof FetchError) {
|
||||
// 400 and 404 are treated as not found
|
||||
if (err.statusCode === 400 || err.statusCode === 404) return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const imdbId = data.external_ids.find(
|
||||
(v) => v.provider === "imdb_latest"
|
||||
)?.external_id;
|
||||
const tmdbId = data.external_ids.find(
|
||||
(v) => v.provider === "tmdb_latest"
|
||||
)?.external_id;
|
||||
|
||||
if (!imdbId || !tmdbId) throw new Error("not enough info");
|
||||
|
||||
let seasonData: JWSeasonMetaResult | undefined;
|
||||
if (data.object_type === "show") {
|
||||
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";
|
||||
const url = makeUrl("/content/titles/show_season/{id}/locale/en_US", {
|
||||
id: seasonToScrape,
|
||||
});
|
||||
seasonData = await proxiedFetch<any>(url, { baseURL: JW_API_BASE });
|
||||
}
|
||||
|
||||
return {
|
||||
meta: formatJWMeta(data, seasonData),
|
||||
imdbId,
|
||||
tmdbId,
|
||||
};
|
||||
}
|
112
src/backend/metadata/justwatch.ts
Normal file
112
src/backend/metadata/justwatch.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types";
|
||||
|
||||
export const JW_API_BASE = "https://apis.justwatch.com";
|
||||
export const JW_IMAGE_BASE = "https://images.justwatch.com";
|
||||
|
||||
export type JWContentTypes = "movie" | "show";
|
||||
|
||||
export type JWSeasonShort = {
|
||||
title: string;
|
||||
id: number;
|
||||
season_number: number;
|
||||
};
|
||||
|
||||
export type JWEpisodeShort = {
|
||||
title: string;
|
||||
id: number;
|
||||
episode_number: number;
|
||||
};
|
||||
|
||||
export type JWMediaResult = {
|
||||
title: string;
|
||||
poster?: string;
|
||||
id: number;
|
||||
original_release_year: number;
|
||||
jw_entity_id: string;
|
||||
object_type: JWContentTypes;
|
||||
seasons?: JWSeasonShort[];
|
||||
};
|
||||
|
||||
export type JWSeasonMetaResult = {
|
||||
title: string;
|
||||
id: string;
|
||||
season_number: number;
|
||||
episodes: JWEpisodeShort[];
|
||||
};
|
||||
|
||||
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
|
||||
if (type === MWMediaType.MOVIE) return "movie";
|
||||
if (type === MWMediaType.SERIES) return "show";
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function JWMediaToMediaType(type: string): MWMediaType {
|
||||
if (type === "movie") return MWMediaType.MOVIE;
|
||||
if (type === "show") return MWMediaType.SERIES;
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function formatJWMeta(
|
||||
media: JWMediaResult,
|
||||
season?: JWSeasonMetaResult
|
||||
): MWMediaMeta {
|
||||
const type = JWMediaToMediaType(media.object_type);
|
||||
let seasons: undefined | MWSeasonMeta[];
|
||||
if (type === MWMediaType.SERIES) {
|
||||
seasons = media.seasons
|
||||
?.sort((a, b) => a.season_number - b.season_number)
|
||||
.map(
|
||||
(v): MWSeasonMeta => ({
|
||||
id: v.id.toString(),
|
||||
number: v.season_number,
|
||||
title: v.title,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title: media.title,
|
||||
id: media.id.toString(),
|
||||
year: media.original_release_year.toString(),
|
||||
poster: media.poster
|
||||
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
|
||||
: undefined,
|
||||
type,
|
||||
seasons: seasons as any,
|
||||
seasonData: season
|
||||
? ({
|
||||
id: season.id.toString(),
|
||||
number: season.season_number,
|
||||
title: season.title,
|
||||
episodes: season.episodes
|
||||
.sort((a, b) => a.episode_number - b.episode_number)
|
||||
.map((v) => ({
|
||||
id: v.id.toString(),
|
||||
number: v.episode_number,
|
||||
title: v.title,
|
||||
})),
|
||||
} as any)
|
||||
: (undefined as any),
|
||||
};
|
||||
}
|
||||
|
||||
export function JWMediaToId(media: MWMediaMeta): string {
|
||||
return ["JW", mediaTypeToJW(media.type), media.id].join("-");
|
||||
}
|
||||
|
||||
export function decodeJWId(
|
||||
paramId: string
|
||||
): { id: string; type: MWMediaType } | null {
|
||||
const [prefix, type, id] = paramId.split("-", 3);
|
||||
if (prefix !== "JW") return null;
|
||||
let mediaType;
|
||||
try {
|
||||
mediaType = JWMediaToMediaType(type);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: mediaType,
|
||||
id,
|
||||
};
|
||||
}
|
58
src/backend/metadata/search.ts
Normal file
58
src/backend/metadata/search.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { SimpleCache } from "@/utils/cache";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import {
|
||||
formatJWMeta,
|
||||
JWContentTypes,
|
||||
JWMediaResult,
|
||||
JW_API_BASE,
|
||||
mediaTypeToJW,
|
||||
} from "./justwatch";
|
||||
import { MWMediaMeta, MWQuery } from "./types";
|
||||
|
||||
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
||||
cache.setCompare((a, b) => {
|
||||
return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim();
|
||||
});
|
||||
cache.initialize();
|
||||
|
||||
type JWSearchQuery = {
|
||||
content_types: JWContentTypes[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
type JWPage<T> = {
|
||||
items: T[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
};
|
||||
|
||||
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
|
||||
if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
|
||||
const { searchQuery, type } = query;
|
||||
|
||||
const contentType = mediaTypeToJW(type);
|
||||
const body: JWSearchQuery = {
|
||||
content_types: [contentType],
|
||||
page: 1,
|
||||
query: searchQuery,
|
||||
page_size: 40,
|
||||
};
|
||||
|
||||
const data = await proxiedFetch<JWPage<JWMediaResult>>(
|
||||
"/content/titles/en_US/popular",
|
||||
{
|
||||
baseURL: JW_API_BASE,
|
||||
params: {
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const returnData = data.items.map<MWMediaMeta>((v) => formatJWMeta(v));
|
||||
cache.set(query, returnData, 3600); // cache for an hour
|
||||
return returnData;
|
||||
}
|
47
src/backend/metadata/types.ts
Normal file
47
src/backend/metadata/types.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
export enum MWMediaType {
|
||||
MOVIE = "movie",
|
||||
SERIES = "series",
|
||||
ANIME = "anime",
|
||||
}
|
||||
|
||||
export type MWSeasonMeta = {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type MWSeasonWithEpisodeMeta = {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
episodes: {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type MWMediaMetaBase = {
|
||||
title: string;
|
||||
id: string;
|
||||
year: string;
|
||||
poster?: string;
|
||||
};
|
||||
|
||||
type MWMediaMetaSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
seasons: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
seasons: MWSeasonMeta[];
|
||||
seasonData: MWSeasonWithEpisodeMeta;
|
||||
};
|
||||
|
||||
export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific;
|
||||
|
||||
export interface MWQuery {
|
||||
searchQuery: string;
|
||||
type: MWMediaType;
|
||||
}
|
66
src/backend/providers/flixhq.ts
Normal file
66
src/backend/providers/flixhq.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { compareTitle } from "@/utils/titleMatch";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const flixHqBase = "https://api.consumet.org/movies/flixhq";
|
||||
|
||||
registerProvider({
|
||||
id: "flixhq",
|
||||
displayName: "FlixHQ",
|
||||
rank: 100,
|
||||
type: [MWMediaType.MOVIE],
|
||||
|
||||
async scrape({ media, progress }) {
|
||||
// search for relevant item
|
||||
const searchResults = await proxiedFetch<any>(
|
||||
`/${encodeURIComponent(media.meta.title)}`,
|
||||
{
|
||||
baseURL: flixHqBase,
|
||||
}
|
||||
);
|
||||
const foundItem = searchResults.results.find((v: any) => {
|
||||
return (
|
||||
compareTitle(v.title, media.meta.title) &&
|
||||
v.releaseDate === media.meta.year
|
||||
);
|
||||
});
|
||||
if (!foundItem) throw new Error("No watchable item found");
|
||||
const flixId = foundItem.id;
|
||||
|
||||
// get media info
|
||||
progress(25);
|
||||
const mediaInfo = await proxiedFetch<any>("/info", {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
id: flixId,
|
||||
},
|
||||
});
|
||||
|
||||
// get stream info from media
|
||||
progress(75);
|
||||
const watchInfo = await proxiedFetch<any>("/watch", {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
episodeId: mediaInfo.episodes[0].id,
|
||||
mediaId: flixId,
|
||||
},
|
||||
});
|
||||
|
||||
// get best quality source
|
||||
const source = watchInfo.sources.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
quality: MWStreamQuality.QUNKNOWN,
|
||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -1,15 +1,10 @@
|
|||
import { unpack } from "unpacker";
|
||||
import CryptoJS from "crypto-js";
|
||||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWProviderMediaResult,
|
||||
} from "@/providers/types";
|
||||
|
||||
import { conf } from "@/config";
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { MWStreamQuality } from "@/backend/helpers/streams";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
const format = {
|
||||
stringify: (cipher: any) => {
|
||||
|
@ -37,52 +32,23 @@ const format = {
|
|||
},
|
||||
};
|
||||
|
||||
export const gDrivePlayerScraper: MWMediaProvider = {
|
||||
registerProvider({
|
||||
id: "gdriveplayer",
|
||||
enabled: true,
|
||||
type: [MWMediaType.MOVIE],
|
||||
displayName: "gdriveplayer",
|
||||
rank: 69,
|
||||
type: [MWMediaType.MOVIE],
|
||||
|
||||
async getMediaFromPortable(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWProviderMediaResult> {
|
||||
const res = await fetch(
|
||||
`${conf().CORS_PROXY_URL}https://api.gdriveplayer.us/v1/imdb/${
|
||||
media.mediaId
|
||||
}`
|
||||
).then((d) => d.json());
|
||||
|
||||
return {
|
||||
...media,
|
||||
title: res.Title,
|
||||
year: res.Year,
|
||||
} as MWProviderMediaResult;
|
||||
},
|
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
|
||||
const searchRes = await fetch(
|
||||
`${
|
||||
conf().CORS_PROXY_URL
|
||||
}https://api.gdriveplayer.us/v1/movie/search?title=${query.searchQuery}`
|
||||
).then((d) => d.json());
|
||||
|
||||
const results: MWProviderMediaResult[] = (searchRes || []).map(
|
||||
(item: any) => ({
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
mediaId: item.imdb,
|
||||
})
|
||||
async scrape({ progress, media: { imdbId } }) {
|
||||
progress(10);
|
||||
const streamRes = await proxiedFetch<string>(
|
||||
"https://database.gdriveplayer.us/player.php",
|
||||
{
|
||||
params: {
|
||||
imdb: imdbId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
||||
const streamRes = await fetch(
|
||||
`${
|
||||
conf().CORS_PROXY_URL
|
||||
}https://database.gdriveplayer.us/player.php?imdb=${media.mediaId}`
|
||||
).then((d) => d.text());
|
||||
progress(90);
|
||||
const page = new DOMParser().parseFromString(streamRes, "text/html");
|
||||
|
||||
const script: HTMLElement | undefined = Array.from(
|
||||
|
@ -105,6 +71,7 @@ export const gDrivePlayerScraper: MWMediaProvider = {
|
|||
{ format }
|
||||
).toString(CryptoJS.enc.Utf8)
|
||||
);
|
||||
|
||||
// eslint-disable-next-line
|
||||
const sources = JSON.parse(
|
||||
JSON.stringify(
|
||||
|
@ -120,6 +87,18 @@ export const gDrivePlayerScraper: MWMediaProvider = {
|
|||
const source = sources[sources.length - 1];
|
||||
/// END
|
||||
|
||||
return { url: `https:${source.file}`, type: source.type, captions: [] };
|
||||
let quality;
|
||||
if (source.label === "720p") quality = MWStreamQuality.Q720P;
|
||||
else quality = MWStreamQuality.QUNKNOWN;
|
||||
|
||||
return {
|
||||
stream: {
|
||||
streamUrl: `https:${source.file}`,
|
||||
type: source.type,
|
||||
quality,
|
||||
captions: [],
|
||||
},
|
||||
embeds: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
235
src/backend/providers/m4ufree.ts
Normal file
235
src/backend/providers/m4ufree.ts
Normal file
|
@ -0,0 +1,235 @@
|
|||
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const HOST = "m4ufree.com";
|
||||
const URL_BASE = `https://${HOST}`;
|
||||
const URL_SEARCH = `${URL_BASE}/search`;
|
||||
const URL_AJAX = `${URL_BASE}/ajax`;
|
||||
const URL_AJAX_TV = `${URL_BASE}/ajaxtv`;
|
||||
|
||||
// * Years can be in one of 4 formats:
|
||||
// * - "startyear" (for movies, EX: 2022)
|
||||
// * - "startyear-" (for TV series which has not ended, EX: 2022-)
|
||||
// * - "startyear-endyear" (for TV series which has ended, EX: 2022-2023)
|
||||
// * - "startyearendyear" (for TV series which has ended, EX: 20222023)
|
||||
const REGEX_TITLE_AND_YEAR = /(.*) \(?(\d*|\d*-|\d*-\d*)\)?$/;
|
||||
const REGEX_TYPE = /.*-(movie|tvshow)-online-free-m4ufree\.html/;
|
||||
const REGEX_COOKIES = /XSRF-TOKEN=(.*?);.*laravel_session=(.*?);/;
|
||||
const REGEX_SEASON_EPISODE = /S(\d*)-E(\d*)/;
|
||||
|
||||
function toDom(html: string) {
|
||||
return new DOMParser().parseFromString(html, "text/html");
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "m4ufree",
|
||||
displayName: "m4ufree",
|
||||
rank: -1,
|
||||
disabled: true, // Disables because the redirector URLs it returns will throw 404 / 403 depending on if you view it in the browser or fetch it respectively. It just does not work.
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, type, episode: episodeId, season: seasonId }) {
|
||||
const season =
|
||||
media.meta.seasons?.find((s) => s.id === seasonId)?.number || 1;
|
||||
const episode =
|
||||
media.meta.type === MWMediaType.SERIES
|
||||
? media.meta.seasonData.episodes.find((ep) => ep.id === episodeId)
|
||||
?.number || 1
|
||||
: undefined;
|
||||
|
||||
const embeds: MWEmbed[] = [];
|
||||
|
||||
/*
|
||||
, {
|
||||
responseType: "text" as any,
|
||||
}
|
||||
*/
|
||||
const responseText = await proxiedFetch<string>(
|
||||
`${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html`
|
||||
);
|
||||
let dom = toDom(responseText);
|
||||
|
||||
const searchResults = [...dom.querySelectorAll(".item")]
|
||||
.map((element) => {
|
||||
const tooltipText = element.querySelector(".tiptitle p")?.innerHTML;
|
||||
if (!tooltipText) return;
|
||||
|
||||
let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText);
|
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = regexResult[1];
|
||||
const year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year
|
||||
const a = element.querySelector("a");
|
||||
if (!a) return;
|
||||
const href = a.href;
|
||||
|
||||
regexResult = REGEX_TYPE.exec(href);
|
||||
|
||||
if (!regexResult || !regexResult[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scraperDeterminedType = regexResult[1];
|
||||
|
||||
scraperDeterminedType =
|
||||
scraperDeterminedType === "tvshow" ? "show" : "movie"; // * Map to Trakt type
|
||||
|
||||
return { type: scraperDeterminedType, title, year, href };
|
||||
})
|
||||
.filter((item) => item);
|
||||
|
||||
const mediaInResults = searchResults.find(
|
||||
(item) =>
|
||||
item &&
|
||||
item.title === media.meta.title &&
|
||||
item.year.toString() === media.meta.year
|
||||
);
|
||||
|
||||
if (!mediaInResults) {
|
||||
// * Nothing found
|
||||
return {
|
||||
embeds,
|
||||
};
|
||||
}
|
||||
|
||||
let cookies: string | null = "";
|
||||
const responseTextFromMedia = await proxiedFetch<string>(
|
||||
mediaInResults.href,
|
||||
{
|
||||
onResponse(context) {
|
||||
cookies = context.response.headers.get("X-Set-Cookie");
|
||||
},
|
||||
}
|
||||
);
|
||||
dom = toDom(responseTextFromMedia);
|
||||
|
||||
let regexResult = REGEX_COOKIES.exec(cookies);
|
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
// * DO SOMETHING?
|
||||
throw new Error("No regexResults, yikesssssss kinda gross idk");
|
||||
}
|
||||
|
||||
const cookieHeader = `XSRF-TOKEN=${regexResult[1]}; laravel_session=${regexResult[2]}`;
|
||||
|
||||
const token = dom
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute("content");
|
||||
if (!token) return { embeds };
|
||||
|
||||
if (type === MWMediaType.SERIES) {
|
||||
// * Get the season/episode data
|
||||
const episodes = [...dom.querySelectorAll(".episode")]
|
||||
.map((element) => {
|
||||
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML);
|
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newEpisode = Number(regexResult[1]);
|
||||
const newSeason = Number(regexResult[2]);
|
||||
|
||||
return {
|
||||
id: element.getAttribute("idepisode"),
|
||||
episode: newEpisode,
|
||||
season: newSeason,
|
||||
};
|
||||
})
|
||||
.filter((item) => item);
|
||||
|
||||
const ep = episodes.find(
|
||||
(newEp) => newEp && newEp.episode === episode && newEp.season === season
|
||||
);
|
||||
if (!ep) return { embeds };
|
||||
|
||||
const form = `idepisode=${ep.id}&_token=${token}`;
|
||||
|
||||
const response = await proxiedFetch<string>(URL_AJAX_TV, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Sec-CH-UA":
|
||||
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||
"Sec-CH-UA-Mobile": "?0",
|
||||
"Sec-CH-UA-Platform": '"Linux"',
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"X-Cookie": cookieHeader,
|
||||
"X-Origin": URL_BASE,
|
||||
"X-Referer": mediaInResults.href,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
dom = toDom(response);
|
||||
}
|
||||
|
||||
const servers = [...dom.querySelectorAll(".singlemv")].map((element) =>
|
||||
element.getAttribute("data")
|
||||
);
|
||||
|
||||
for (const server of servers) {
|
||||
const form = `m4u=${server}&_token=${token}`;
|
||||
|
||||
const response = await proxiedFetch<string>(URL_AJAX, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Sec-CH-UA":
|
||||
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||
"Sec-CH-UA-Mobile": "?0",
|
||||
"Sec-CH-UA-Platform": '"Linux"',
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"X-Cookie": cookieHeader,
|
||||
"X-Origin": URL_BASE,
|
||||
"X-Referer": mediaInResults.href,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
const serverDom = toDom(response);
|
||||
|
||||
const link = serverDom.querySelector("iframe")?.src;
|
||||
|
||||
const getEmbedType = (url: string) => {
|
||||
if (url.startsWith("https://streamm4u.club"))
|
||||
return MWEmbedType.STREAMM4U;
|
||||
if (url.startsWith("https://play.playm4u.xyz"))
|
||||
return MWEmbedType.PLAYM4U;
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!link) continue;
|
||||
|
||||
const embedType = getEmbedType(link);
|
||||
if (embedType) {
|
||||
embeds.push({
|
||||
url: link,
|
||||
type: embedType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(embeds);
|
||||
return {
|
||||
embeds,
|
||||
};
|
||||
},
|
||||
});
|
128
src/backend/providers/netfilm.ts
Normal file
128
src/backend/providers/netfilm.ts
Normal file
|
@ -0,0 +1,128 @@
|
|||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const netfilmBase = "https://net-film.vercel.app";
|
||||
|
||||
const qualityMap = {
|
||||
"360": MWStreamQuality.Q360P,
|
||||
"480": MWStreamQuality.Q480P,
|
||||
"720": MWStreamQuality.Q720P,
|
||||
"1080": MWStreamQuality.Q1080P,
|
||||
};
|
||||
type QualityInMap = keyof typeof qualityMap;
|
||||
|
||||
registerProvider({
|
||||
id: "netfilm",
|
||||
displayName: "NetFilm",
|
||||
rank: 150,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
// search for relevant item
|
||||
const searchResponse = await proxiedFetch<any>(
|
||||
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
|
||||
{
|
||||
baseURL: netfilmBase,
|
||||
}
|
||||
);
|
||||
|
||||
const searchResults = searchResponse.data.results;
|
||||
progress(25);
|
||||
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
const foundItem = searchResults.find((v: any) => {
|
||||
return v.name === media.meta.title && v.releaseTime === media.meta.year;
|
||||
});
|
||||
if (!foundItem) throw new Error("No watchable item found");
|
||||
const netfilmId = foundItem.id;
|
||||
|
||||
// get stream info from media
|
||||
progress(75);
|
||||
const watchInfo = await proxiedFetch<any>(
|
||||
`/api/episode?id=${netfilmId}`,
|
||||
{
|
||||
baseURL: netfilmBase,
|
||||
}
|
||||
);
|
||||
|
||||
const { qualities } = watchInfo.data;
|
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
type: MWStreamType.HLS,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Unsupported type");
|
||||
|
||||
const desiredSeason = media.meta.seasonData.number;
|
||||
|
||||
const searchItems = searchResults
|
||||
.filter((v: any) => {
|
||||
return v.name.includes(media.meta.title);
|
||||
})
|
||||
.map((v: any) => {
|
||||
return {
|
||||
...v,
|
||||
season: parseInt(v.name.split(" ").at(-1), 10) || 1,
|
||||
};
|
||||
});
|
||||
|
||||
const foundItem = searchItems.find((v: any) => {
|
||||
return v.season === desiredSeason;
|
||||
});
|
||||
|
||||
progress(50);
|
||||
const seasonDetail = await proxiedFetch<any>(
|
||||
`/api/detail?id=${foundItem.id}&category=${foundItem.categoryTag[0].id}`,
|
||||
{
|
||||
baseURL: netfilmBase,
|
||||
}
|
||||
);
|
||||
|
||||
const episodeNo = media.meta.seasonData.episodes.find(
|
||||
(v: any) => v.id === episode
|
||||
)?.number;
|
||||
const episodeData = seasonDetail.data.episodeVo.find(
|
||||
(v: any) => v.seriesNo === episodeNo
|
||||
);
|
||||
|
||||
progress(75);
|
||||
const episodeStream = await proxiedFetch<any>(
|
||||
`/api/episode?id=${foundItem.id}&category=1&episode=${episodeData.id}`,
|
||||
{
|
||||
baseURL: netfilmBase,
|
||||
}
|
||||
);
|
||||
|
||||
const { qualities } = episodeStream.data;
|
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
type: MWStreamType.HLS,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
249
src/backend/providers/superstream/index.ts
Normal file
249
src/backend/providers/superstream/index.ts
Normal file
|
@ -0,0 +1,249 @@
|
|||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
|
||||
import { customAlphabet } from "nanoid";
|
||||
import CryptoJS from "crypto-js";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import {
|
||||
MWCaption,
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { compareTitle } from "@/utils/titleMatch";
|
||||
|
||||
const nanoid = customAlphabet("0123456789abcdef", 32);
|
||||
|
||||
const qualityMap = {
|
||||
"360p": MWStreamQuality.Q360P,
|
||||
"480p": MWStreamQuality.Q480P,
|
||||
"720p": MWStreamQuality.Q720P,
|
||||
"1080p": MWStreamQuality.Q1080P,
|
||||
};
|
||||
type QualityInMap = keyof typeof qualityMap;
|
||||
|
||||
// CONSTANTS, read below (taken from og)
|
||||
// We do not want content scanners to notice this scraping going on so we've hidden all constants
|
||||
// The source has its origins in China so I added some extra security with banned words
|
||||
// Mayhaps a tiny bit unethical, but this source is just too good :)
|
||||
// If you are copying this code please use precautions so they do not change their api.
|
||||
const iv = atob("d0VpcGhUbiE=");
|
||||
const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2");
|
||||
const apiUrls = [
|
||||
atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="),
|
||||
atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="),
|
||||
];
|
||||
const appKey = atob("bW92aWVib3g=");
|
||||
const appId = atob("Y29tLnRkby5zaG93Ym94");
|
||||
|
||||
// cryptography stuff
|
||||
const crypto = {
|
||||
encrypt(str: string) {
|
||||
return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), {
|
||||
iv: CryptoJS.enc.Utf8.parse(iv),
|
||||
}).toString();
|
||||
},
|
||||
getVerify(str: string, str2: string, str3: string) {
|
||||
if (str) {
|
||||
return CryptoJS.MD5(
|
||||
CryptoJS.MD5(str2).toString() + str3 + str
|
||||
).toString();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
// get expire time
|
||||
const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12);
|
||||
|
||||
// sending requests
|
||||
const get = (data: object, altApi = false) => {
|
||||
const defaultData = {
|
||||
childmode: "0",
|
||||
app_version: "11.5",
|
||||
appid: appId,
|
||||
lang: "en",
|
||||
expired_date: `${expiry()}`,
|
||||
platform: "android",
|
||||
channel: "Website",
|
||||
};
|
||||
const encryptedData = crypto.encrypt(
|
||||
JSON.stringify({
|
||||
...defaultData,
|
||||
...data,
|
||||
})
|
||||
);
|
||||
const appKeyHash = CryptoJS.MD5(appKey).toString();
|
||||
const verify = crypto.getVerify(encryptedData, appKey, key);
|
||||
const body = JSON.stringify({
|
||||
app_key: appKeyHash,
|
||||
verify,
|
||||
encrypt_data: encryptedData,
|
||||
});
|
||||
const b64Body = btoa(body);
|
||||
|
||||
const formatted = new URLSearchParams();
|
||||
formatted.append("data", b64Body);
|
||||
formatted.append("appid", "27");
|
||||
formatted.append("platform", "android");
|
||||
formatted.append("version", "129");
|
||||
formatted.append("medium", "Website");
|
||||
|
||||
const requestUrl = altApi ? apiUrls[1] : apiUrls[0];
|
||||
return proxiedFetch<any>(requestUrl, {
|
||||
method: "POST",
|
||||
parseResponse: JSON.parse,
|
||||
headers: {
|
||||
Platform: "android",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `${formatted.toString()}&token${nanoid()}`,
|
||||
});
|
||||
};
|
||||
|
||||
// Find best resolution
|
||||
const getBestQuality = (list: any[]) => {
|
||||
return (
|
||||
list.find((quality: any) => quality.quality === "1080p" && quality.path) ??
|
||||
list.find((quality: any) => quality.quality === "720p" && quality.path) ??
|
||||
list.find((quality: any) => quality.quality === "480p" && quality.path) ??
|
||||
list.find((quality: any) => quality.quality === "360p" && quality.path)
|
||||
);
|
||||
};
|
||||
|
||||
registerProvider({
|
||||
id: "superstream",
|
||||
displayName: "Superstream",
|
||||
rank: 200,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
// Find Superstream ID for show
|
||||
const searchQuery = {
|
||||
module: "Search3",
|
||||
page: "1",
|
||||
type: "all",
|
||||
keyword: media.meta.title,
|
||||
pagelimit: "20",
|
||||
};
|
||||
const searchRes = (await get(searchQuery, true)).data;
|
||||
progress(33);
|
||||
|
||||
const superstreamEntry = searchRes.find(
|
||||
(res: any) =>
|
||||
compareTitle(res.title, media.meta.title) &&
|
||||
res.year === Number(media.meta.year)
|
||||
);
|
||||
|
||||
if (!superstreamEntry) throw new Error("No entry found on SuperStream");
|
||||
const superstreamId = superstreamEntry.id;
|
||||
|
||||
// Movie logic
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
const apiQuery = {
|
||||
uid: "",
|
||||
module: "Movie_downloadurl_v3",
|
||||
mid: superstreamId,
|
||||
oss: "1",
|
||||
group: "",
|
||||
};
|
||||
|
||||
const mediaRes = (await get(apiQuery)).data;
|
||||
progress(50);
|
||||
|
||||
const hdQuality = getBestQuality(mediaRes.list);
|
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found.");
|
||||
|
||||
const subtitleApiQuery = {
|
||||
fid: hdQuality.fid,
|
||||
uid: "",
|
||||
module: "Movie_srt_list_v2",
|
||||
mid: superstreamId,
|
||||
};
|
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||
|
||||
const mappedCaptions = subtitleRes.list.map(
|
||||
(subtitle: any): MWCaption => {
|
||||
return {
|
||||
needsProxy: true,
|
||||
langIso: subtitle.language,
|
||||
url: subtitle.subtitles[0].file_path,
|
||||
type: MWCaptionType.SRT,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: hdQuality.path,
|
||||
quality: qualityMap[hdQuality.quality as QualityInMap],
|
||||
type: MWStreamType.MP4,
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Unsupported type");
|
||||
|
||||
// Fetch requested episode
|
||||
const apiQuery = {
|
||||
uid: "",
|
||||
module: "TV_downloadurl_v3",
|
||||
tid: superstreamId,
|
||||
season: media.meta.seasonData.number.toString(),
|
||||
episode: (
|
||||
media.meta.seasonData.episodes.find(
|
||||
(episodeInfo) => episodeInfo.id === episode
|
||||
)?.number ?? 1
|
||||
).toString(),
|
||||
oss: "1",
|
||||
group: "",
|
||||
};
|
||||
|
||||
const mediaRes = (await get(apiQuery)).data;
|
||||
progress(66);
|
||||
|
||||
const hdQuality = getBestQuality(mediaRes.list);
|
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found.");
|
||||
|
||||
const subtitleApiQuery = {
|
||||
fid: hdQuality.fid,
|
||||
uid: "",
|
||||
module: "TV_srt_list_v2",
|
||||
episode:
|
||||
media.meta.seasonData.episodes.find(
|
||||
(episodeInfo) => episodeInfo.id === episode
|
||||
)?.number ?? 1,
|
||||
tid: superstreamId,
|
||||
season: media.meta.seasonData.number.toString(),
|
||||
};
|
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||
|
||||
const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => {
|
||||
return {
|
||||
needsProxy: true,
|
||||
langIso: subtitle.language,
|
||||
url: subtitle.subtitles[0].file_path,
|
||||
type: MWCaptionType.SRT,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
quality: qualityMap[
|
||||
hdQuality.quality as QualityInMap
|
||||
] as MWStreamQuality,
|
||||
streamUrl: hdQuality.path,
|
||||
type: MWStreamType.MP4,
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
25
src/components/Button.tsx
Normal file
25
src/components/Button.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
icon?: Icons;
|
||||
onClick?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Button(props: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
className="inline-flex items-center justify-center rounded-lg bg-white px-8 py-3 font-bold text-black transition-[transform,background-color] duration-100 hover:bg-gray-200 active:scale-105 md:px-16"
|
||||
>
|
||||
{props.icon ? (
|
||||
<span className="mr-3 hidden md:inline-block">
|
||||
<Icon icon={props.icon} />
|
||||
</span>
|
||||
) : null}
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
|
@ -57,5 +57,5 @@ export function Dropdown(props: DropdownProps) {
|
|||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import { memo, useEffect, useRef } from "react";
|
||||
|
||||
export enum Icons {
|
||||
SEARCH = "search",
|
||||
BOOKMARK = "bookmark",
|
||||
BOOKMARK_OUTLINE = "bookmark_outline",
|
||||
CLOCK = "clock",
|
||||
EYE_SLASH = "eyeSlash",
|
||||
ARROW_LEFT = "arrowLeft",
|
||||
ARROW_RIGHT = "arrowRight",
|
||||
CHEVRON_DOWN = "chevronDown",
|
||||
CHEVRON_RIGHT = "chevronRight",
|
||||
CHEVRON_LEFT = "chevronLeft",
|
||||
CLAPPER_BOARD = "clapperBoard",
|
||||
FILM = "film",
|
||||
DRAGON = "dragon",
|
||||
|
@ -14,6 +18,22 @@ export enum Icons {
|
|||
MOVIE_WEB = "movieWeb",
|
||||
DISCORD = "discord",
|
||||
GITHUB = "github",
|
||||
PLAY = "play",
|
||||
PAUSE = "pause",
|
||||
EXPAND = "expand",
|
||||
COMPRESS = "compress",
|
||||
VOLUME = "volume",
|
||||
VOLUME_X = "volume_x",
|
||||
X = "x",
|
||||
EDIT = "edit",
|
||||
AIRPLAY = "airplay",
|
||||
EPISODES = "episodes",
|
||||
SKIP_FORWARD = "skip_forward",
|
||||
SKIP_BACKWARD = "skip_backward",
|
||||
FILE = "file",
|
||||
CAPTIONS = "captions",
|
||||
LINK = "link",
|
||||
CASTING = "casting",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
|
@ -29,6 +49,7 @@ const iconList: Record<Icons, string> = {
|
|||
arrowLeft: `<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-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>`,
|
||||
chevronDown: `<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-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
|
||||
chevronRight: `<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-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
|
||||
chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-left"><polyline points="15 18 9 12 15 6"></polyline></svg>`,
|
||||
clapperBoard: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`,
|
||||
film: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M463.1 32h-416C21.49 32-.0001 53.49-.0001 80v352c0 26.51 21.49 48 47.1 48h416c26.51 0 48-21.49 48-48v-352C511.1 53.49 490.5 32 463.1 32zM111.1 408c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 408zM111.1 280c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM111.1 152c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 152zM351.1 400c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V400zM351.1 208c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V208zM463.1 408c0 4.418-3.582 8-8 8h-47.1c-4.418 0-7.1-3.582-7.1-8l0-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V408zM463.1 280c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM463.1 152c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8l0-48c0-4.418 3.582-8 7.1-8h47.1c4.418 0 8 3.582 8 8V152z"/></svg>`,
|
||||
dragon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M18.43 255.8L192 224L100.8 292.6C90.67 302.8 97.8 320 112 320h222.7c-9.499-26.5-14.75-54.5-14.75-83.38V194.2L200.3 106.8C176.5 90.88 145 92.75 123.3 111.2l-117.5 116.4C-6.562 238 2.436 258 18.43 255.8zM575.2 289.9l-100.7-50.25c-16.25-8.125-26.5-24.75-26.5-43V160h63.99l28.12 22.62C546.1 188.6 554.2 192 562.7 192h30.1c11.1 0 23.12-6.875 28.5-17.75l14.37-28.62c5.374-10.87 4.25-23.75-2.999-33.5l-74.49-99.37C552.1 4.75 543.5 0 533.5 0H296C288.9 0 285.4 8.625 290.4 13.62L351.1 64L292.4 88.75c-5.874 3-5.874 11.37 0 14.37L351.1 128l-.0011 108.6c0 72 35.99 139.4 95.99 179.4c-195.6 6.75-344.4 41-434.1 60.88c-8.124 1.75-13.87 9-13.87 17.38C.0463 504 8.045 512 17.79 512h499.1c63.24 0 119.6-47.5 122.1-110.8C642.3 354 617.1 310.9 575.2 289.9zM489.1 66.25l45.74 11.38c-2.75 11-12.5 18.88-24.12 18.25C497.7 95.25 484.8 83.38 489.1 66.25z"/></svg>`,
|
||||
|
@ -37,13 +58,46 @@ const iconList: Record<Icons, string> = {
|
|||
movieWeb: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20.927 20.927"><path d="M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z" transform="translate(10.018 -7.425) rotate(45)" fill="currentColor"/></svg>`,
|
||||
discord: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`,
|
||||
github: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 496 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>`,
|
||||
play: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" style="transform: translateX(5%)" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>`,
|
||||
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M48 64C21.5 64 0 85.5 0 112V400c0 26.5 21.5 48 48 48H80c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zm192 0c-26.5 0-48 21.5-48 48V400c0 26.5 21.5 48 48 48h32c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H240z"/></svg>`,
|
||||
expand: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64v96c0 17.7 14.3 32 32 32s32-14.3 32-32V96h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H64V352zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64v64c0 17.7 14.3 32 32 32s32-14.3 32-32V64c0-17.7-14.3-32-32-32H320zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H320c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V352z"/></svg>`,
|
||||
compress: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M160 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V64zM32 320c-17.7 0-32 14.3-32 32s14.3 32 32 32H96v64c0 17.7 14.3 32 32 32s32-14.3 32-32V352c0-17.7-14.3-32-32-32H32zM352 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H352V64zM320 320c-17.7 0-32 14.3-32 32v96c0 17.7 14.3 32 32 32s32-14.3 32-32V384h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H320z"/></svg>`,
|
||||
volume: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>`,
|
||||
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
|
||||
x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"/></svg>`,
|
||||
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>`,
|
||||
bookmark_outline: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M336 0h-288C21.49 0 0 21.49 0 48v431.9c0 24.7 26.79 40.08 48.12 27.64L192 423.6l143.9 83.93C357.2 519.1 384 504.6 384 479.9V48C384 21.49 362.5 0 336 0zM336 452L192 368l-144 84V54C48 50.63 50.63 48 53.1 48h276C333.4 48 336 50.63 336 54V452z"/></svg>`,
|
||||
airplay: `<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-airplay"><path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"></path><polygon fill="currentColor" points="12 15 17 21 7 21 12 15"></polygon></svg>`,
|
||||
episodes: `<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4C1.34315 4 0 5.34314 0 7V13.9496C0 15.6065 1.34315 16.9496 3 16.9496H5.86645V14.9496H3C2.44772 14.9496 2 14.5019 2 13.9496V7C2 6.44771 2.44771 6 3 6H16.0327C16.585 6 17.0327 6.44772 17.0327 7V9.86645H19.0327V7C19.0327 5.34315 17.6896 4 16.0327 4H3Z" fill="currentColor"/><rect x="5.89929" y="10.5444" width="17" height="10" rx="2" stroke="currentColor" stroke-width="2"/></svg>`,
|
||||
skip_forward: `<svg width="1em" height="1em" viewBox="0 0 26 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.3333 12.3333L16 7.66667M16 7.66667L11.3333 3M16 7.66667H6.66667C5.42899 7.66667 4.242 8.15833 3.36684 9.0335C2.49167 9.90867 2 11.0957 2 12.3333C2 13.571 2.49167 14.758 3.36684 15.6332C4.242 16.5083 5.42899 17 6.66667 17H9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16.5043 14.2727V23H14.6591V16.0241H14.608L12.6094 17.277V15.6406L14.7699 14.2727H16.5043ZM22.0004 23.1918C21.2674 23.1889 20.6367 23.0085 20.1083 22.6506C19.5827 22.2926 19.1779 21.7741 18.8938 21.0952C18.6126 20.4162 18.4734 19.5994 18.4762 18.6449C18.4762 17.6932 18.6168 16.8821 18.8981 16.2116C19.1822 15.5412 19.587 15.0312 20.1126 14.6818C20.641 14.3295 21.2702 14.1534 22.0004 14.1534C22.7305 14.1534 23.3583 14.3295 23.8839 14.6818C24.4123 15.0341 24.8185 15.5455 25.1026 16.2159C25.3867 16.8835 25.5273 17.6932 25.5245 18.6449C25.5245 19.6023 25.3825 20.4205 25.0984 21.0994C24.8171 21.7784 24.4137 22.2969 23.8881 22.6548C23.3626 23.0128 22.7333 23.1918 22.0004 23.1918ZM22.0004 21.6619C22.5004 21.6619 22.8995 21.4105 23.1978 20.9077C23.4961 20.4048 23.6438 19.6506 23.641 18.6449C23.641 17.983 23.5728 17.4318 23.4364 16.9915C23.3029 16.5511 23.1126 16.2202 22.8654 15.9986C22.6211 15.777 22.3327 15.6662 22.0004 15.6662C21.5032 15.6662 21.1055 15.9148 20.8072 16.4119C20.5089 16.9091 20.3583 17.6534 20.3555 18.6449C20.3555 19.3153 20.4222 19.875 20.5558 20.3239C20.6921 20.7699 20.8839 21.1051 21.131 21.3295C21.3782 21.5511 21.668 21.6619 22.0004 21.6619Z" fill="currentColor" /></svg>`,
|
||||
skip_backward: `<svg width="1em" height="1em" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`,
|
||||
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>`,
|
||||
casting: "",
|
||||
};
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
function ChromeCastButton() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const tag = document.createElement("google-cast-launcher");
|
||||
tag.setAttribute("id", "castbutton");
|
||||
ref.current?.appendChild(tag);
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} className="h-6" />;
|
||||
}
|
||||
|
||||
export const Icon = memo((props: IconProps) => {
|
||||
if (props.icon === Icons.CASTING) {
|
||||
return <ChromeCastButton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
|
||||
className={props.className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
20
src/components/Overlay.tsx
Normal file
20
src/components/Overlay.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { Transition } from "@/components/Transition";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
export function Overlay(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<body data-no-scroll />
|
||||
</Helmet>
|
||||
<div className="fixed inset-0 z-[99999]">
|
||||
<Transition
|
||||
animation="fade"
|
||||
className="absolute inset-0 bg-[rgba(8,6,18,0.85)]"
|
||||
isChild
|
||||
/>
|
||||
{props.children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
||||
import { useState } from "react";
|
||||
import { MWMediaType, MWQuery } from "@/providers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DropdownButton } from "./buttons/DropdownButton";
|
||||
import { Icons } from "./Icon";
|
||||
import { Icon, Icons } from "./Icon";
|
||||
import { TextInputControl } from "./text-inputs/TextInputControl";
|
||||
|
||||
export interface SearchBarProps {
|
||||
|
@ -37,42 +37,43 @@ export function SearchBarInput(props: SearchBarProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 rounded-[28px] bg-denim-300 px-4 py-4 transition-colors focus-within:bg-denim-400 hover:bg-denim-400 sm:flex-row sm:py-2 sm:pl-8 sm:pr-2">
|
||||
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
|
||||
<div className="pointer-events-none absolute left-5 top-0 bottom-0 flex max-h-14 items-center">
|
||||
<Icon icon={Icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
<TextInputControl
|
||||
onUnFocus={props.onUnFocus}
|
||||
onChange={(val) => setSearch(val)}
|
||||
value={props.value.searchQuery}
|
||||
className="w-full flex-1 bg-transparent text-white placeholder-denim-700 focus:outline-none"
|
||||
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2"
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
|
||||
<DropdownButton
|
||||
icon={Icons.SEARCH}
|
||||
open={dropdownOpen}
|
||||
setOpen={(val) => setDropdownOpen(val)}
|
||||
selectedItem={props.value.type}
|
||||
setSelectedItem={(val) => setType(val)}
|
||||
options={[
|
||||
{
|
||||
id: MWMediaType.MOVIE,
|
||||
name: t('searchBar.movie'),
|
||||
icon: Icons.FILM,
|
||||
},
|
||||
{
|
||||
id: MWMediaType.SERIES,
|
||||
name: t('searchBar.series'),
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
// {
|
||||
// id: MWMediaType.ANIME,
|
||||
// name: "Anime",
|
||||
// icon: Icons.DRAGON,
|
||||
// },
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
{props.buttonText || t('searchBar.search')}
|
||||
</DropdownButton>
|
||||
<div className="px-4 py-4 pt-0 sm:py-2 sm:px-2">
|
||||
<DropdownButton
|
||||
icon={Icons.SEARCH}
|
||||
open={dropdownOpen}
|
||||
setOpen={(val) => setDropdownOpen(val)}
|
||||
selectedItem={props.value.type}
|
||||
setSelectedItem={(val) => setType(val)}
|
||||
options={[
|
||||
{
|
||||
id: MWMediaType.MOVIE,
|
||||
name: t("searchBar.movie"),
|
||||
icon: Icons.FILM,
|
||||
},
|
||||
{
|
||||
id: MWMediaType.SERIES,
|
||||
name: t("searchBar.series"),
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
{props.buttonText || t("searchBar.search")}
|
||||
</DropdownButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
75
src/components/Transition.tsx
Normal file
75
src/components/Transition.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { Fragment, ReactNode } from "react";
|
||||
import {
|
||||
Transition as HeadlessTransition,
|
||||
TransitionClasses,
|
||||
} from "@headlessui/react";
|
||||
|
||||
type TransitionAnimations = "slide-down" | "slide-up" | "fade" | "none";
|
||||
|
||||
interface Props {
|
||||
show?: boolean;
|
||||
durationClass?: string;
|
||||
animation: TransitionAnimations;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
isChild?: boolean;
|
||||
}
|
||||
|
||||
function getClasses(
|
||||
animation: TransitionAnimations,
|
||||
duration: string
|
||||
): TransitionClasses {
|
||||
if (animation === "slide-down") {
|
||||
return {
|
||||
leave: `transition-[transform,opacity] ${duration}`,
|
||||
leaveFrom: "opacity-100 translate-y-0",
|
||||
leaveTo: "-translate-y-4 opacity-0",
|
||||
enter: `transition-[transform,opacity] ${duration}`,
|
||||
enterFrom: "opacity-0 -translate-y-4",
|
||||
enterTo: "translate-y-0 opacity-100",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "slide-up") {
|
||||
return {
|
||||
leave: `transition-[transform,opacity] ${duration}`,
|
||||
leaveFrom: "opacity-100 translate-y-0",
|
||||
leaveTo: "translate-y-4 opacity-0",
|
||||
enter: `transition-[transform,opacity] ${duration}`,
|
||||
enterFrom: "opacity-0 translate-y-4",
|
||||
enterTo: "translate-y-0 opacity-100",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "fade") {
|
||||
return {
|
||||
leave: `transition-[transform,opacity] ${duration}`,
|
||||
leaveFrom: "opacity-100",
|
||||
leaveTo: "opacity-0",
|
||||
enter: `transition-[transform,opacity] ${duration}`,
|
||||
enterFrom: "opacity-0",
|
||||
enterTo: "opacity-100",
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function Transition(props: Props) {
|
||||
const duration = props.durationClass ?? "duration-200";
|
||||
const classes = getClasses(props.animation, duration);
|
||||
|
||||
if (props.isChild) {
|
||||
return (
|
||||
<HeadlessTransition.Child as={Fragment} {...classes}>
|
||||
<div className={props.className}>{props.children}</div>
|
||||
</HeadlessTransition.Child>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HeadlessTransition show={props.show} as={Fragment} {...classes}>
|
||||
<div className={props.className}>{props.children}</div>
|
||||
</HeadlessTransition>
|
||||
);
|
||||
}
|
|
@ -6,7 +6,7 @@ import React, {
|
|||
} from "react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { Backdrop, useBackdrop } from "@/components/layout/Backdrop";
|
||||
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
|
||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface OptionItem {
|
||||
|
@ -56,7 +56,7 @@ export const DropdownButton = React.forwardRef<
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
let id: NodeJS.Timeout;
|
||||
let id: ReturnType<typeof setTimeout>;
|
||||
|
||||
if (props.open) {
|
||||
setDelayedSelectedId(props.selectedItem);
|
||||
|
@ -93,37 +93,43 @@ export const DropdownButton = React.forwardRef<
|
|||
className="relative w-full sm:w-auto"
|
||||
{...highlightedProps}
|
||||
>
|
||||
<ButtonControl
|
||||
{...props}
|
||||
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-200 px-4 py-2 text-white hover:bg-bink-300"
|
||||
<BackdropContainer
|
||||
onClick={() => props.setOpen(false)}
|
||||
{...backdropProps}
|
||||
>
|
||||
<Icon icon={selectedItem.icon} />
|
||||
<span className="flex-1">{selectedItem.name}</span>
|
||||
<Icon
|
||||
icon={Icons.CHEVRON_DOWN}
|
||||
className={`transition-transform ${props.open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</ButtonControl>
|
||||
<div
|
||||
className={`absolute top-0 z-10 w-full rounded-[20px] bg-denim-300 pt-[40px] transition-all duration-200 ${
|
||||
props.open
|
||||
? "block max-h-60 opacity-100"
|
||||
: "invisible max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{props.options
|
||||
.filter((opt) => opt.id !== delayedSelectedId)
|
||||
.map((opt) => (
|
||||
<Option
|
||||
option={opt}
|
||||
key={opt.id}
|
||||
onClick={(e) => onOptionClick(e, opt)}
|
||||
tabIndex={props.open ? 0 : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ButtonControl
|
||||
{...props}
|
||||
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-400 px-4 py-2 text-white hover:bg-bink-300"
|
||||
>
|
||||
<Icon icon={selectedItem.icon} />
|
||||
<span className="flex-1">{selectedItem.name}</span>
|
||||
<Icon
|
||||
icon={Icons.CHEVRON_DOWN}
|
||||
className={`transition-transform ${
|
||||
props.open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</ButtonControl>
|
||||
<div
|
||||
className={`absolute top-0 z-10 w-full rounded-[20px] bg-denim-300 pt-[40px] transition-all duration-200 ${
|
||||
props.open
|
||||
? "block max-h-60 opacity-100"
|
||||
: "invisible max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{props.options
|
||||
.filter((opt) => opt.id !== delayedSelectedId)
|
||||
.map((opt) => (
|
||||
<Option
|
||||
option={opt}
|
||||
key={opt.id}
|
||||
onClick={(e) => onOptionClick(e, opt)}
|
||||
tabIndex={props.open ? 0 : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BackdropContainer>
|
||||
</div>
|
||||
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
36
src/components/buttons/EditButton.tsx
Normal file
36
src/components/buttons/EditButton.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface EditButtonProps {
|
||||
editing: boolean;
|
||||
onEdit?: (editing: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditButton(props: EditButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [parent] = useAutoAnimate<HTMLSpanElement>();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
props.onEdit?.(!props.editing);
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<ButtonControl
|
||||
onClick={onClick}
|
||||
className="flex h-12 items-center overflow-hidden rounded-full bg-denim-400 px-4 py-2 text-white transition-[background-color,transform] hover:bg-denim-500 active:scale-105"
|
||||
>
|
||||
<span ref={parent}>
|
||||
{props.editing ? (
|
||||
<span className="mx-4 whitespace-nowrap">
|
||||
{t("media.stopEditing")}
|
||||
</span>
|
||||
) : (
|
||||
<Icon icon={Icons.EDIT} />
|
||||
)}
|
||||
</span>
|
||||
</ButtonControl>
|
||||
);
|
||||
}
|
|
@ -6,17 +6,24 @@ export interface IconPatchProps {
|
|||
clickable?: boolean;
|
||||
className?: string;
|
||||
icon: Icons;
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
export function IconPatch(props: IconPatchProps) {
|
||||
const clickableClasses = props.clickable
|
||||
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
|
||||
: "";
|
||||
const transparentClasses = props.transparent
|
||||
? "bg-opacity-0 hover:bg-opacity-50"
|
||||
: "";
|
||||
const activeClasses = props.active
|
||||
? "border-bink-600 bg-bink-100 text-bink-600"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className={props.className || undefined} onClick={props.onClick}>
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-300 transition-[color,transform,border-color] duration-75 ${
|
||||
props.clickable
|
||||
? "cursor-pointer hover:scale-110 hover:bg-denim-400 hover:text-white active:scale-125"
|
||||
: ""
|
||||
} ${props.active ? "border-bink-600 bg-bink-100 text-bink-600" : ""}`}
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-500 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses}`}
|
||||
>
|
||||
<Icon icon={props.icon} />
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import React, { createRef, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useFade } from "@/hooks/useFade";
|
||||
|
||||
interface BackdropProps {
|
||||
|
@ -39,7 +40,7 @@ export function useBackdrop(): [
|
|||
return [setBackdrop, backdropProps, highlightedProps];
|
||||
}
|
||||
|
||||
export function Backdrop(props: BackdropProps) {
|
||||
function Backdrop(props: BackdropProps) {
|
||||
const clickEvent = props.onClick || (() => {});
|
||||
const animationEvent = props.onBackdropHide || (() => {});
|
||||
const [isVisible, setVisible, fadeProps] = useFade();
|
||||
|
@ -58,7 +59,7 @@ export function Backdrop(props: BackdropProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 left-0 right-0 z-[999] h-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||
className={`pointer-events-auto fixed left-0 right-0 top-0 h-screen w-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||
!isVisible ? "opacity-0" : ""
|
||||
}`}
|
||||
{...fadeProps}
|
||||
|
@ -66,3 +67,47 @@ export function Backdrop(props: BackdropProps) {
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackdropContainer(
|
||||
props: {
|
||||
children: React.ReactNode;
|
||||
} & BackdropProps
|
||||
) {
|
||||
const root = createRef<HTMLDivElement>();
|
||||
const copy = createRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
let frame = -1;
|
||||
function poll() {
|
||||
if (root.current && copy.current) {
|
||||
const rect = root.current.getBoundingClientRect();
|
||||
copy.current.style.top = `${rect.top}px`;
|
||||
copy.current.style.left = `${rect.left}px`;
|
||||
copy.current.style.width = `${rect.width}px`;
|
||||
copy.current.style.height = `${rect.height}px`;
|
||||
}
|
||||
frame = window.requestAnimationFrame(poll);
|
||||
}
|
||||
poll();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
// we dont want this to run only on mount, dont care about ref updates
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [root, copy]);
|
||||
|
||||
return (
|
||||
<div ref={root}>
|
||||
{createPortal(
|
||||
<div className="pointer-events-none fixed top-0 left-0 z-[999]">
|
||||
<Backdrop active={props.active} {...props} />
|
||||
<div ref={copy} className="pointer-events-auto absolute">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<div className="invisible">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
export function BrandPill(props: { clickable?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center space-x-2 rounded-full bg-bink-100 bg-opacity-50 px-4 py-2 text-bink-600 ${props.clickable
|
||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-200 hover:text-bink-700 active:scale-95"
|
||||
className={`flex items-center space-x-2 rounded-full bg-bink-300 bg-opacity-50 px-4 py-2 text-bink-600 ${
|
||||
props.clickable
|
||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-400 hover:text-bink-700 active:scale-95"
|
||||
: ""
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
||||
<span className="font-semibold text-white">{t('global.name')}</span>
|
||||
<span className="font-semibold text-white">{t("global.name")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,7 +3,65 @@ import { IconPatch } from "@/components/buttons/IconPatch";
|
|||
import { Icons } from "@/components/Icon";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
interface ErrorShowcaseProps {
|
||||
error: {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ErrorShowcase(props: ErrorShowcaseProps) {
|
||||
return (
|
||||
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
|
||||
<p className="mb-1 break-words font-bold text-white">
|
||||
{props.error.name} - {props.error.description}
|
||||
</p>
|
||||
<p className="break-words">{props.error.path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorMessageProps {
|
||||
error?: {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
};
|
||||
localSize?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ErrorMessage(props: ErrorMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
props.localSize ? "h-full" : "min-h-screen"
|
||||
} flex w-full flex-col items-center justify-center px-4 py-12`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-start text-center">
|
||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
||||
<Title>{t("media.errors.genericTitle")}</Title>
|
||||
{props.children ? (
|
||||
<p className="my-6 max-w-lg">{props.children}</p>
|
||||
) : (
|
||||
<p className="my-6 max-w-lg">
|
||||
<Trans i18nKey="media.errors.videoFailed">
|
||||
<Link url={conf().DISCORD_LINK} newTab />
|
||||
<Link url={conf().GITHUB_LINK} newTab />
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{props.error ? <ErrorShowcase error={props.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
|
@ -50,33 +108,6 @@ export class ErrorBoundary extends Component<
|
|||
render() {
|
||||
if (!this.state.hasError) return this.props.children as any;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div className="flex flex-col items-center justify-start text-center">
|
||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
||||
<Title>Whoops, it broke</Title>
|
||||
<p className="my-6 max-w-lg">
|
||||
The app encountered an error and wasn't able to recover, please
|
||||
report it to the{" "}
|
||||
<Link url={conf().DISCORD_LINK} newTab>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
or on{" "}
|
||||
<Link url={conf().GITHUB_LINK} newTab>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{this.state.error ? (
|
||||
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
|
||||
<p className="mb-1 break-words font-bold text-white">
|
||||
{this.state.error.name} - {this.state.error.description}
|
||||
</p>
|
||||
<p className="break-words">{this.state.error.path}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return <ErrorMessage error={this.state.error} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,10 +8,10 @@ export function Loading(props: LoadingProps) {
|
|||
<div className={props.className}>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="flex h-12 items-center justify-center">
|
||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full" />
|
||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:150ms]" />
|
||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:300ms]" />
|
||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:450ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:150ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:300ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:450ms]" />
|
||||
</div>
|
||||
{props.text && props.text.length ? (
|
||||
<p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p>
|
||||
|
|
44
src/components/layout/Modal.tsx
Normal file
44
src/components/layout/Modal.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Overlay } from "@/components/Overlay";
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ModalFrame(props: Props) {
|
||||
return (
|
||||
<Transition
|
||||
className="fixed inset-0 z-[9999]"
|
||||
animation="none"
|
||||
show={props.show}
|
||||
>
|
||||
<Overlay>
|
||||
<Transition
|
||||
isChild
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
animation="slide-up"
|
||||
>
|
||||
{props.children}
|
||||
</Transition>
|
||||
</Overlay>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal(props: Props) {
|
||||
return createPortal(
|
||||
<ModalFrame show={props.show}>{props.children}</ModalFrame>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalCard(props: { children?: ReactNode }) {
|
||||
return (
|
||||
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -2,17 +2,25 @@ import { ReactNode } from "react";
|
|||
import { Link } from "react-router-dom";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
children?: ReactNode;
|
||||
bg?: boolean;
|
||||
}
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-0 flex min-h-[88px] items-center justify-between py-5 px-7">
|
||||
<div className="flex w-full items-center justify-center sm:w-fit">
|
||||
<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={`${
|
||||
props.bg ? "opacity-100" : "opacity-0"
|
||||
} absolute inset-0 block bg-denim-100 transition-opacity duration-300`}
|
||||
>
|
||||
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
|
||||
</div>
|
||||
<div className="relative flex w-full items-center justify-center sm:w-fit">
|
||||
<div className="mr-auto sm:mr-6">
|
||||
<Link to="/">
|
||||
<BrandPill clickable />
|
||||
|
@ -23,7 +31,7 @@ export function Navigation(props: NavigationProps) {
|
|||
<div
|
||||
className={`${
|
||||
props.children ? "hidden sm:flex" : "flex"
|
||||
} flex-row gap-4`}
|
||||
} relative flex-row gap-4`}
|
||||
>
|
||||
<a
|
||||
href={conf().DISCORD_LINK}
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export interface PaperProps {
|
||||
children?: ReactNode,
|
||||
className?: string,
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Paper(props: PaperProps) {
|
||||
return (
|
||||
<div className={`bg-denim-200 lg:rounded-xl px-4 sm:px-8 md:px-12 py-6 sm:py-8 md:py-12 ${props.className}`}>
|
||||
<div
|
||||
className={`bg-denim-200 px-4 py-6 sm:px-8 sm:py-8 md:px-12 md:py-12 lg:rounded-xl ${props.className}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
39
src/components/layout/ProgressRing.tsx
Normal file
39
src/components/layout/ProgressRing.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
interface Props {
|
||||
className?: string;
|
||||
radius?: number;
|
||||
percentage: number;
|
||||
backingRingClassname?: string;
|
||||
}
|
||||
|
||||
export function ProgressRing(props: Props) {
|
||||
const radius = props.radius ?? 40;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`${props.className ?? ""} -rotate-90`}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
className={`fill-transparent stroke-denim-700 stroke-[15] opacity-25 ${
|
||||
props.backingRingClassname ?? ""
|
||||
}`}
|
||||
r={radius}
|
||||
cx="50"
|
||||
cy="50"
|
||||
/>
|
||||
<circle
|
||||
className="fill-transparent stroke-current stroke-[15] transition-[stroke-dashoffset] duration-150"
|
||||
r={radius}
|
||||
cx="50"
|
||||
cy="50"
|
||||
style={{
|
||||
strokeDasharray: `${2 * Math.PI * radius} ${2 * Math.PI * radius}`,
|
||||
strokeDashoffset: `${
|
||||
2 * Math.PI * radius -
|
||||
(props.percentage / 100) * (2 * Math.PI * radius)
|
||||
}`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -1,124 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Dropdown, OptionItem } from "@/components/Dropdown";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { WatchedEpisode } from "@/components/media/WatchedEpisodeButton";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { serializePortableMedia } from "@/hooks/usePortableMedia";
|
||||
import {
|
||||
convertMediaToPortable,
|
||||
MWMedia,
|
||||
MWMediaSeasons,
|
||||
MWMediaSeason,
|
||||
MWPortableMedia,
|
||||
} from "@/providers";
|
||||
import { getSeasonDataFromMedia } from "@/providers/methods/seasons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SeasonsProps {
|
||||
media: MWMedia;
|
||||
}
|
||||
|
||||
export function LoadingSeasons(props: { error?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="mb-3 mt-5 h-10 w-56 rounded bg-denim-400 opacity-50" />
|
||||
</div>
|
||||
{!props.error ? (
|
||||
<>
|
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3">
|
||||
<IconPatch icon={Icons.WARNING} className="text-red-400" />
|
||||
<p>{t('seasons.failed')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Seasons(props: SeasonsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [searchSeasons, loading, error, success] = useLoading(
|
||||
(portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia)
|
||||
);
|
||||
const history = useHistory();
|
||||
const [seasons, setSeasons] = useState<MWMediaSeasons>({ seasons: [] });
|
||||
const seasonSelected = props.media.seasonId as string;
|
||||
const episodeSelected = props.media.episodeId as string;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const seasonData = await searchSeasons(props.media);
|
||||
setSeasons(seasonData);
|
||||
})();
|
||||
}, [searchSeasons, props.media]);
|
||||
|
||||
function navigateToSeasonAndEpisode(seasonId: string, episodeId: string) {
|
||||
const newMedia: MWMedia = { ...props.media };
|
||||
newMedia.episodeId = episodeId;
|
||||
newMedia.seasonId = seasonId;
|
||||
history.replace(
|
||||
`/media/${newMedia.mediaType}/${serializePortableMedia(
|
||||
convertMediaToPortable(newMedia)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const mapSeason = (season: MWMediaSeason) => ({
|
||||
id: season.id,
|
||||
name: season.title || `${t('seasons.season', { season: season.sort })}`,
|
||||
});
|
||||
|
||||
const options = seasons.seasons.map(mapSeason);
|
||||
|
||||
const foundSeason = seasons.seasons.find(
|
||||
(season) => season.id === seasonSelected
|
||||
);
|
||||
const selectedItem = foundSeason ? mapSeason(foundSeason) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? <LoadingSeasons /> : null}
|
||||
{error ? <LoadingSeasons error /> : null}
|
||||
{success && seasons.seasons.length ? (
|
||||
<>
|
||||
<Dropdown
|
||||
selectedItem={selectedItem as OptionItem}
|
||||
options={options}
|
||||
setSelectedItem={(seasonItem) =>
|
||||
navigateToSeasonAndEpisode(
|
||||
seasonItem.id,
|
||||
seasons.seasons.find((s) => s.id === seasonItem.id)?.episodes[0]
|
||||
.id as string
|
||||
)
|
||||
}
|
||||
/>
|
||||
{seasons.seasons
|
||||
.find((s) => s.id === seasonSelected)
|
||||
?.episodes.map((v) => (
|
||||
<WatchedEpisode
|
||||
key={v.id}
|
||||
media={{
|
||||
...props.media,
|
||||
seriesData: seasons,
|
||||
episodeId: v.id,
|
||||
seasonId: seasonSelected,
|
||||
}}
|
||||
active={v.id === episodeSelected}
|
||||
onClick={() => navigateToSeasonAndEpisode(seasonSelected, v.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,13 +1,10 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
icon?: Icons;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
linkText?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
@ -23,15 +20,8 @@ export function SectionHeading(props: SectionHeadingProps) {
|
|||
) : null}
|
||||
{props.title}
|
||||
</p>
|
||||
{props.linkText ? (
|
||||
<ArrowLink
|
||||
linkText={props.linkText}
|
||||
direction="left"
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
) : null}
|
||||
{props.children}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
20
src/components/layout/Spinner.css
Normal file
20
src/components/layout/Spinner.css
Normal file
|
@ -0,0 +1,20 @@
|
|||
.spinner {
|
||||
font-size: 48px;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 0.12em solid var(--color,white);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: spinner-rotation 800ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
9
src/components/layout/Spinner.tsx
Normal file
9
src/components/layout/Spinner.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import "./Spinner.css";
|
||||
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Spinner(props: SpinnerProps) {
|
||||
return <div className={["spinner", props.className ?? ""].join(" ")} />;
|
||||
}
|
|
@ -8,7 +8,9 @@ interface ThinContainerProps {
|
|||
export function ThinContainer(props: ThinContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[600px] mx-auto px-2 sm:px-0 ${props.classNames || ""}`}
|
||||
className={`mx-auto w-[600px] max-w-full px-2 sm:px-0 ${
|
||||
props.classNames || ""
|
||||
}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
|
18
src/components/layout/WideContainer.tsx
Normal file
18
src/components/layout/WideContainer.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
interface WideContainerProps {
|
||||
classNames?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function WideContainer(props: WideContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`mx-auto w-[700px] max-w-full px-8 sm:px-4 ${
|
||||
props.classNames || ""
|
||||
}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -9,12 +9,12 @@ export function Episode(props: EpisodeProps) {
|
|||
return (
|
||||
<div
|
||||
onClick={props.onClick}
|
||||
className={`bg-denim-500 hover:bg-denim-400 transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded font-bold text-white active:scale-110 ${
|
||||
props.active ? "shadow-bink-500 shadow-[inset_0_0_0_2px]" : ""
|
||||
className={`transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
|
||||
props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50"
|
||||
className="absolute bottom-0 top-0 left-0 bg-bink-500 bg-opacity-50"
|
||||
style={{
|
||||
width: `${props.progress || 0}%`,
|
||||
}}
|
||||
|
|
|
@ -1,97 +1,129 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
convertMediaToPortable,
|
||||
getProviderFromId,
|
||||
MWMediaMeta,
|
||||
MWMediaType,
|
||||
} from "@/providers";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { serializePortableMedia } from "@/hooks/usePortableMedia";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DotList } from "@/components/text/DotList";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { JWMediaToId } from "@/backend/metadata/justwatch";
|
||||
import { Icons } from "../Icon";
|
||||
import { IconPatch } from "../buttons/IconPatch";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
watchedPercentage: number;
|
||||
linkable?: boolean;
|
||||
series?: boolean;
|
||||
series?: {
|
||||
episode: number;
|
||||
season: number;
|
||||
episodeId: string;
|
||||
seasonId: string;
|
||||
};
|
||||
percentage?: number;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function MediaCardContent({
|
||||
media,
|
||||
linkable,
|
||||
watchedPercentage,
|
||||
series,
|
||||
percentage,
|
||||
closable,
|
||||
onClose,
|
||||
}: MediaCardProps) {
|
||||
const provider = getProviderFromId(media.providerId);
|
||||
const { t } = useTranslation();
|
||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const canLink = linkable && !closable;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group relative mb-4 flex overflow-hidden rounded bg-denim-300 py-4 px-5 ${
|
||||
linkable ? "hover:bg-denim-400" : ""
|
||||
<div
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||
canLink ? "hover:bg-opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
{/* progress background */}
|
||||
{watchedPercentage > 0 ? (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0">
|
||||
<article
|
||||
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||
canLink ? "group-hover:scale-95" : ""
|
||||
}`}
|
||||
>
|
||||
<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"
|
||||
style={{
|
||||
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
||||
}}
|
||||
>
|
||||
{series ? (
|
||||
<div className="absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors group-hover:bg-denim-500">
|
||||
<p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white">
|
||||
{t("seasons.seasonAndEpisode", {
|
||||
season: series.season,
|
||||
episode: series.episode,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{percentage !== undefined ? (
|
||||
<>
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-bink-700"
|
||||
style={{
|
||||
width: percentageString,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="relative h-full bg-bink-300 bg-opacity-30"
|
||||
style={{
|
||||
width: `${watchedPercentage}%`,
|
||||
}}
|
||||
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
|
||||
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="absolute right-0 top-0 bottom-0 ml-auto w-40 bg-gradient-to-l from-bink-400 to-transparent opacity-40" />
|
||||
<IconPatch
|
||||
clickable
|
||||
className="text-2xl text-slate-400"
|
||||
onClick={() => closable && onClose?.()}
|
||||
icon={Icons.X}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative flex flex-1">
|
||||
{/* card content */}
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-1 font-bold text-white">
|
||||
{media.title}
|
||||
{series && media.seasonId && media.episodeId ? (
|
||||
<span className="ml-2 text-xs text-denim-700">
|
||||
S{media.seasonId} E{media.episodeId}
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[provider.displayName, media.mediaType, media.year]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* hoverable chevron */}
|
||||
<div
|
||||
className={`flex translate-x-3 items-center justify-end text-xl text-white opacity-0 transition-[opacity,transform] ${
|
||||
linkable ? "group-hover:translate-x-0 group-hover:opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_RIGHT} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
||||
<span>{media.title}</span>
|
||||
</h1>
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[t(`media.${media.type}`), media.year]}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaCard(props: MediaCardProps) {
|
||||
let link = "movie";
|
||||
if (props.media.mediaType === MWMediaType.SERIES) link = "series";
|
||||
|
||||
const content = <MediaCardContent {...props} />;
|
||||
|
||||
const canLink = props.linkable && !props.closable;
|
||||
|
||||
let link = canLink
|
||||
? `/media/${encodeURIComponent(JWMediaToId(props.media))}`
|
||||
: "#";
|
||||
if (canLink && props.series)
|
||||
link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent(
|
||||
props.series.episodeId
|
||||
)}`;
|
||||
|
||||
if (!props.linkable) return <span>{content}</span>;
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${link}/${serializePortableMedia(
|
||||
convertMediaToPortable(props.media)
|
||||
)}`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
return <Link to={link}>{content}</Link>;
|
||||
}
|
||||
|
|
15
src/components/media/MediaGrid.tsx
Normal file
15
src/components/media/MediaGrid.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { forwardRef } from "react";
|
||||
|
||||
interface MediaGridProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -1,109 +0,0 @@
|
|||
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { MWMediaCaption, MWMediaStream } from "@/providers";
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
source: MWMediaStream;
|
||||
captions: MWMediaCaption[];
|
||||
startAt?: number;
|
||||
onProgress?: (event: ProgressEvent) => void;
|
||||
}
|
||||
|
||||
export function SkeletonVideoPlayer(props: { error?: boolean }) {
|
||||
return (
|
||||
<div className="flex aspect-video w-full items-center justify-center bg-denim-200 lg:rounded-xl">
|
||||
{props.error ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<IconPatch icon={Icons.WARNING} className="text-red-400" />
|
||||
<p className="mt-5 text-white">Couldn't get your stream</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<Loading />
|
||||
<p className="mt-3 text-white">Getting your stream...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoPlayer(props: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [hasErrored, setErrored] = useState(false);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const showVideo = !isLoading && !hasErrored;
|
||||
const mustUseHls = props.source.type === "m3u8";
|
||||
|
||||
// reset if stream url changes
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setErrored(false);
|
||||
|
||||
// hls support
|
||||
if (mustUseHls) {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (!Hls.isSupported()) {
|
||||
setLoading(false);
|
||||
setErrored(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const hls = new Hls();
|
||||
|
||||
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
videoRef.current.src = props.source.url;
|
||||
return;
|
||||
}
|
||||
|
||||
hls.attachMedia(videoRef.current);
|
||||
hls.loadSource(props.source.url);
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
setErrored(true);
|
||||
console.error(data);
|
||||
});
|
||||
}
|
||||
}, [props.source.url, videoRef, mustUseHls]);
|
||||
|
||||
let skeletonUi: null | ReactElement = null;
|
||||
if (hasErrored) {
|
||||
skeletonUi = <SkeletonVideoPlayer error />;
|
||||
} else if (isLoading) {
|
||||
skeletonUi = <SkeletonVideoPlayer />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{skeletonUi}
|
||||
<video
|
||||
className={`w-full rounded-xl bg-black ${!showVideo ? "hidden" : ""}`}
|
||||
ref={videoRef}
|
||||
onProgress={(e) =>
|
||||
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
|
||||
}
|
||||
onLoadedData={(e) => {
|
||||
setLoading(false);
|
||||
if (props.startAt)
|
||||
(e.target as HTMLVideoElement).currentTime = props.startAt;
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error("failed to playback stream", e);
|
||||
setErrored(true);
|
||||
}}
|
||||
controls
|
||||
autoPlay
|
||||
>
|
||||
{!mustUseHls ? (
|
||||
<source src={props.source.url} type="video/mp4" />
|
||||
) : null}
|
||||
{props.captions.map((v) => (
|
||||
<track key={v.id} kind="captions" label={v.label} src={v.url} />
|
||||
))}
|
||||
</video>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import { getEpisodeFromMedia, MWMedia } from "@/providers";
|
||||
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched";
|
||||
import { Episode } from "./EpisodeButton";
|
||||
|
||||
export interface WatchedEpisodeProps {
|
||||
media: MWMedia;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function WatchedEpisode(props: WatchedEpisodeProps) {
|
||||
const { watched } = useWatchedContext();
|
||||
const foundWatched = getWatchedFromPortable(watched.items, props.media);
|
||||
const episode = getEpisodeFromMedia(props.media);
|
||||
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
|
||||
|
||||
return (
|
||||
<Episode
|
||||
progress={watchedPercentage}
|
||||
episodeNumber={episode?.episode?.sort ?? 1}
|
||||
active={props.active}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,23 +1,44 @@
|
|||
import { MWMediaMeta } from "@/providers";
|
||||
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { useWatchedContext } from "@/state/watched";
|
||||
import { useMemo } from "react";
|
||||
import { MediaCard } from "./MediaCard";
|
||||
|
||||
export interface WatchedMediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
series?: boolean;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function formatSeries(
|
||||
obj:
|
||||
| { episodeId: string; seasonId: string; episode: number; season: number }
|
||||
| undefined
|
||||
) {
|
||||
if (!obj) return undefined;
|
||||
return {
|
||||
season: obj.season,
|
||||
episode: obj.episode,
|
||||
episodeId: obj.episodeId,
|
||||
seasonId: obj.seasonId,
|
||||
};
|
||||
}
|
||||
|
||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||
const { watched } = useWatchedContext();
|
||||
const foundWatched = getWatchedFromPortable(watched.items, props.media);
|
||||
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
|
||||
const watchedMedia = useMemo(() => {
|
||||
return watched.items
|
||||
.sort((a, b) => b.watchedAt - a.watchedAt)
|
||||
.find((v) => v.item.meta.id === props.media.id);
|
||||
}, [watched, props.media]);
|
||||
|
||||
return (
|
||||
<MediaCard
|
||||
watchedPercentage={watchedPercentage}
|
||||
media={props.media}
|
||||
series={props.series && props.media.episodeId !== undefined}
|
||||
series={formatSeries(watchedMedia?.item?.series)}
|
||||
linkable
|
||||
percentage={watchedMedia?.percentage}
|
||||
onClose={props.onClose}
|
||||
closable={props.closable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ export interface DotListProps {
|
|||
|
||||
export function DotList(props: DotListProps) {
|
||||
return (
|
||||
<p className={`text-denim-700 font-semibold ${props.className || ""}`}>
|
||||
<p className={`font-semibold text-denim-700 ${props.className || ""}`}>
|
||||
{props.content.map((item, index) => (
|
||||
<span key={item}>
|
||||
{index !== 0 ? (
|
||||
|
|
|
@ -16,22 +16,27 @@ interface ILinkPropsInternal extends ILinkPropsBase {
|
|||
to: string;
|
||||
}
|
||||
|
||||
type LinkProps =
|
||||
| ILinkPropsExternal
|
||||
| ILinkPropsInternal
|
||||
| ILinkPropsBase;
|
||||
type LinkProps = ILinkPropsExternal | ILinkPropsInternal | ILinkPropsBase;
|
||||
|
||||
export function Link(props: LinkProps) {
|
||||
const isExternal = !!(props as ILinkPropsExternal).url;
|
||||
const isInternal = !!(props as ILinkPropsInternal).to;
|
||||
const content = (
|
||||
<span className="text-bink-600 hover:text-bink-700 cursor-pointer font-bold">
|
||||
<span className="cursor-pointer font-bold text-bink-600 hover:text-bink-700">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isExternal)
|
||||
return <a target={(props as ILinkPropsExternal).newTab ? "_blank" : undefined} rel="noreferrer" href={(props as ILinkPropsExternal).url}>{content}</a>;
|
||||
return (
|
||||
<a
|
||||
target={(props as ILinkPropsExternal).newTab ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
href={(props as ILinkPropsExternal).url}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
if (isInternal)
|
||||
return (
|
||||
<LinkRouter to={(props as ILinkPropsInternal).to}>{content}</LinkRouter>
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
export interface TaglineProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tagline(props: TaglineProps) {
|
||||
return <p className="font-bold text-bink-600">{props.children}</p>;
|
||||
}
|
|
@ -1,7 +1,16 @@
|
|||
export interface TitleProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Title(props: TitleProps) {
|
||||
return <h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white">{props.children}</h1>;
|
||||
return (
|
||||
<h1
|
||||
className={`text-2xl font-bold text-white sm:text-3xl md:text-4xl ${
|
||||
props.className ?? ""
|
||||
}`}
|
||||
>
|
||||
{props.children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||
export const GITHUB_LINK = "https://github.com/JamesHawkinss/movie-web";
|
||||
export const APP_VERSION = "2.1.0";
|
110
src/hooks/useChromecastAvailable.ts
Normal file
110
src/hooks/useChromecastAvailable.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
/// <reference types="chromecast-caf-sender"/>
|
||||
|
||||
import { isChromecastAvailable } from "@/setup/chromecast";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useChromecastAvailable() {
|
||||
const [available, setAvailable] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
isChromecastAvailable((bool) => setAvailable(bool));
|
||||
}, []);
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
export function useChromecast() {
|
||||
const available = useChromecastAvailable();
|
||||
const instance = useRef<cast.framework.CastContext | null>(null);
|
||||
const remotePlayerController =
|
||||
useRef<cast.framework.RemotePlayerController | null>(null);
|
||||
|
||||
function startCast() {
|
||||
const movieMeta = new chrome.cast.media.MovieMediaMetadata();
|
||||
movieMeta.title = "Big Buck Bunny";
|
||||
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo("hello", "video/mp4");
|
||||
(mediaInfo as any).contentUrl =
|
||||
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
|
||||
mediaInfo.metadata = movieMeta;
|
||||
|
||||
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||
request.autoplay = true;
|
||||
|
||||
const session = instance.current?.getCurrentSession();
|
||||
console.log("testing", session);
|
||||
if (!session) return;
|
||||
|
||||
session
|
||||
.loadMedia(request)
|
||||
.then(() => {
|
||||
console.log("Media is loaded");
|
||||
})
|
||||
.catch((e: any) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
function stopCast() {
|
||||
const session = instance.current?.getCurrentSession();
|
||||
if (!session) return;
|
||||
|
||||
const controller = remotePlayerController.current;
|
||||
if (!controller) return;
|
||||
controller.stop();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) return;
|
||||
|
||||
// setup instance if not already
|
||||
if (!instance.current) {
|
||||
const ins = cast.framework.CastContext.getInstance();
|
||||
ins.setOptions({
|
||||
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
instance.current = ins;
|
||||
}
|
||||
|
||||
// setup player if not already
|
||||
if (!remotePlayerController.current) {
|
||||
const player = new cast.framework.RemotePlayer();
|
||||
const controller = new cast.framework.RemotePlayerController(player);
|
||||
remotePlayerController.current = controller;
|
||||
}
|
||||
|
||||
// setup event listener
|
||||
function listenToEvents(e: cast.framework.RemotePlayerChangedEvent) {
|
||||
console.log("chromecast event", e);
|
||||
}
|
||||
function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) {
|
||||
console.log("chromecast event connection changed", e);
|
||||
}
|
||||
remotePlayerController.current.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
|
||||
listenToEvents
|
||||
);
|
||||
remotePlayerController.current.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
|
||||
connectionChanged
|
||||
);
|
||||
|
||||
return () => {
|
||||
remotePlayerController.current?.removeEventListener(
|
||||
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
|
||||
listenToEvents
|
||||
);
|
||||
remotePlayerController.current?.removeEventListener(
|
||||
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
|
||||
connectionChanged
|
||||
);
|
||||
};
|
||||
}, [available]);
|
||||
|
||||
return {
|
||||
startCast,
|
||||
stopCast,
|
||||
};
|
||||
}
|
|
@ -4,17 +4,14 @@ export function useDebounce<T>(value: T, delay: number): T {
|
|||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[value, delay]
|
||||
);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import './useFade.css'
|
||||
import "./useFade.css";
|
||||
|
||||
export const useFade = (initial = false): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => {
|
||||
export const useFade = (
|
||||
initial = false
|
||||
): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => {
|
||||
const [show, setShow] = useState<boolean>(initial);
|
||||
const [isVisible, setVisible] = useState<boolean>(show);
|
||||
|
||||
|
@ -20,7 +22,7 @@ export const useFade = (initial = false): [boolean, React.Dispatch<React.SetStat
|
|||
// These props go on the fading DOM element
|
||||
const fadeProps = {
|
||||
style,
|
||||
onAnimationEnd
|
||||
onAnimationEnd,
|
||||
};
|
||||
|
||||
return [isVisible, setShow, fadeProps];
|
||||
|
|
12
src/hooks/useGoBack.ts
Normal file
12
src/hooks/useGoBack.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { useCallback } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
export function useGoBack() {
|
||||
const reactHistory = useHistory();
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (reactHistory.action !== "POP") reactHistory.goBack();
|
||||
else reactHistory.push("/");
|
||||
}, [reactHistory]);
|
||||
return goBack;
|
||||
}
|
28
src/hooks/useIsMobile.ts
Normal file
28
src/hooks/useIsMobile.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const isMobileCurrent = useRef<boolean | null>(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
const value = window.innerWidth < 1024;
|
||||
const isChanged = isMobileCurrent.current !== value;
|
||||
if (!isChanged) return;
|
||||
|
||||
isMobileCurrent.current = value;
|
||||
setIsMobile(value);
|
||||
}
|
||||
|
||||
onResize();
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
};
|
||||
}
|
|
@ -2,7 +2,12 @@ import React, { useMemo, useRef, useState } from "react";
|
|||
|
||||
export function useLoading<T extends (...args: any) => Promise<any>>(
|
||||
action: T
|
||||
) {
|
||||
): [
|
||||
(...args: Parameters<T>) => ReturnType<T> | Promise<undefined>,
|
||||
boolean,
|
||||
Error | undefined,
|
||||
boolean
|
||||
] {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<any | undefined>(undefined);
|
||||
|
@ -20,11 +25,11 @@ export function useLoading<T extends (...args: any) => Promise<any>>(
|
|||
|
||||
const doAction = useMemo(
|
||||
() =>
|
||||
async (...args: Parameters<T>) => {
|
||||
async (...args: any) => {
|
||||
setLoading(true);
|
||||
setSuccess(false);
|
||||
setError(undefined);
|
||||
return new Promise((resolve) => {
|
||||
return new Promise<any>((resolve) => {
|
||||
actionMemo(...args)
|
||||
.then((v) => {
|
||||
if (!isMounted.current) return resolve(undefined);
|
||||
|
@ -35,6 +40,7 @@ export function useLoading<T extends (...args: any) => Promise<any>>(
|
|||
.catch((err) => {
|
||||
if (isMounted) {
|
||||
setError(err);
|
||||
console.error("USELOADING ERROR", err);
|
||||
setSuccess(false);
|
||||
}
|
||||
resolve(undefined);
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { MWPortableMedia } from "@/providers";
|
||||
|
||||
export function deserializePortableMedia(media: string): MWPortableMedia {
|
||||
return JSON.parse(atob(decodeURIComponent(media)));
|
||||
}
|
||||
|
||||
export function serializePortableMedia(media: MWPortableMedia): string {
|
||||
const data = encodeURIComponent(btoa(JSON.stringify(media)));
|
||||
return data;
|
||||
}
|
||||
|
||||
export function usePortableMedia(): MWPortableMedia | undefined {
|
||||
const { media } = useParams<{ media: string }>();
|
||||
const [mediaObject, setMediaObject] = useState<MWPortableMedia | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setMediaObject(deserializePortableMedia(media));
|
||||
} catch (err) {
|
||||
console.error("Failed to deserialize portable media", err);
|
||||
setMediaObject(undefined);
|
||||
}
|
||||
}, [media, setMediaObject]);
|
||||
|
||||
return mediaObject;
|
||||
}
|
91
src/hooks/useProgressBar.ts
Normal file
91
src/hooks/useProgressBar.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import React, { RefObject, useCallback, useEffect, useState } from "react";
|
||||
|
||||
type ActivityEvent =
|
||||
| React.MouseEvent<HTMLElement>
|
||||
| React.TouchEvent<HTMLElement>
|
||||
| MouseEvent
|
||||
| TouchEvent;
|
||||
|
||||
export function makePercentageString(num: number) {
|
||||
return `${num.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function makePercentage(num: number) {
|
||||
return Number(Math.max(0, Math.min(num, 100)).toFixed(2));
|
||||
}
|
||||
|
||||
function isClickEvent(
|
||||
evt: ActivityEvent
|
||||
): evt is React.MouseEvent<HTMLElement> | MouseEvent {
|
||||
return (
|
||||
evt.type === "mousedown" ||
|
||||
evt.type === "mouseup" ||
|
||||
evt.type === "mousemove"
|
||||
);
|
||||
}
|
||||
|
||||
const getEventX = (evt: ActivityEvent) => {
|
||||
return isClickEvent(evt) ? evt.pageX : evt.changedTouches[0].pageX;
|
||||
};
|
||||
|
||||
export function useProgressBar(
|
||||
barRef: RefObject<HTMLElement>,
|
||||
commit: (percentage: number) => void,
|
||||
commitImmediately = false
|
||||
) {
|
||||
const [mouseDown, setMouseDown] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
function mouseMove(ev: ActivityEvent) {
|
||||
if (!mouseDown || !barRef.current) return;
|
||||
const rect = barRef.current.getBoundingClientRect();
|
||||
const pos = (getEventX(ev) - rect.left) / barRef.current.offsetWidth;
|
||||
setProgress(pos * 100);
|
||||
if (commitImmediately) commit(pos);
|
||||
}
|
||||
|
||||
function mouseUp(ev: ActivityEvent) {
|
||||
if (!mouseDown) return;
|
||||
setMouseDown(false);
|
||||
document.body.removeAttribute("data-no-select");
|
||||
|
||||
if (!barRef.current) return;
|
||||
const rect = barRef.current.getBoundingClientRect();
|
||||
const pos = (getEventX(ev) - rect.left) / barRef.current.offsetWidth;
|
||||
commit(pos);
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", mouseMove);
|
||||
document.addEventListener("touchmove", mouseMove);
|
||||
document.addEventListener("mouseup", mouseUp);
|
||||
document.addEventListener("touchend", mouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", mouseMove);
|
||||
document.removeEventListener("touchmove", mouseMove);
|
||||
document.removeEventListener("mouseup", mouseUp);
|
||||
document.removeEventListener("touchend", mouseUp);
|
||||
};
|
||||
}, [mouseDown, barRef, commit, commitImmediately]);
|
||||
|
||||
const dragMouseDown = useCallback(
|
||||
(ev: ActivityEvent) => {
|
||||
setMouseDown(true);
|
||||
document.body.setAttribute("data-no-select", "true");
|
||||
|
||||
if (!barRef.current) return;
|
||||
const rect = barRef.current.getBoundingClientRect();
|
||||
const pos =
|
||||
((getEventX(ev) - rect.left) / barRef.current.offsetWidth) * 100;
|
||||
setProgress(pos);
|
||||
},
|
||||
[setProgress, barRef]
|
||||
);
|
||||
|
||||
return {
|
||||
dragging: mouseDown,
|
||||
dragPercentage: progress,
|
||||
dragMouseDown,
|
||||
};
|
||||
}
|
74
src/hooks/useScrape.ts
Normal file
74
src/hooks/useScrape.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { findBestStream } from "@/backend/helpers/scrape";
|
||||
import { MWStream } from "@/backend/helpers/streams";
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface ScrapeEventLog {
|
||||
type: "provider" | "embed";
|
||||
errored: boolean;
|
||||
percentage: number;
|
||||
eventId: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type SelectedMediaData =
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: string;
|
||||
season: string;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode: undefined;
|
||||
season: undefined;
|
||||
};
|
||||
|
||||
export function useScrape(meta: DetailedMeta, selected: SelectedMediaData) {
|
||||
const [eventLog, setEventLog] = useState<ScrapeEventLog[]>([]);
|
||||
const [stream, setStream] = useState<MWStream | null>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setPending(true);
|
||||
setStream(null);
|
||||
setEventLog([]);
|
||||
(async () => {
|
||||
const scrapedStream = await findBestStream({
|
||||
media: meta,
|
||||
...selected,
|
||||
onNext(ctx) {
|
||||
setEventLog((arr) => [
|
||||
...arr,
|
||||
{
|
||||
errored: false,
|
||||
id: ctx.id,
|
||||
eventId: ctx.eventId,
|
||||
type: ctx.type,
|
||||
percentage: 0,
|
||||
},
|
||||
]);
|
||||
},
|
||||
onProgress(ctx) {
|
||||
setEventLog((arr) => {
|
||||
const item = arr.reverse().find((v) => v.id === ctx.id);
|
||||
if (item) {
|
||||
item.errored = ctx.errored;
|
||||
item.percentage = ctx.percentage;
|
||||
}
|
||||
return [...arr];
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
setPending(false);
|
||||
setStream(scrapedStream);
|
||||
})();
|
||||
}, [meta, selected]);
|
||||
|
||||
return {
|
||||
stream,
|
||||
pending,
|
||||
eventLog,
|
||||
};
|
||||
}
|
|
@ -1,6 +1,14 @@
|
|||
import React, { useRef, useState } from "react";
|
||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
||||
import { useState } from "react";
|
||||
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
|
||||
import { MWMediaType, MWQuery } from "@/providers";
|
||||
|
||||
function getInitialValue(params: { type: string; query: string }) {
|
||||
const type =
|
||||
Object.values(MWMediaType).find((v) => params.type === v) ||
|
||||
MWMediaType.MOVIE;
|
||||
const searchQuery = params.query || "";
|
||||
return { type, searchQuery };
|
||||
}
|
||||
|
||||
export function useSearchQuery(): [
|
||||
MWQuery,
|
||||
|
@ -8,12 +16,8 @@ export function useSearchQuery(): [
|
|||
() => void
|
||||
] {
|
||||
const history = useHistory();
|
||||
const isFirstRender = useRef(true);
|
||||
const { path, params } = useRouteMatch<{ type: string; query: string }>();
|
||||
const [search, setSearch] = useState<MWQuery>({
|
||||
searchQuery: "",
|
||||
type: MWMediaType.MOVIE,
|
||||
});
|
||||
const [search, setSearch] = useState<MWQuery>(getInitialValue(params));
|
||||
|
||||
const updateParams = (inp: Partial<MWQuery>, force: boolean) => {
|
||||
const copySearch: MWQuery = { ...search };
|
||||
|
@ -38,18 +42,5 @@ export function useSearchQuery(): [
|
|||
);
|
||||
};
|
||||
|
||||
// only run on first load of the page
|
||||
React.useEffect(() => {
|
||||
if (isFirstRender.current === false) {
|
||||
return;
|
||||
}
|
||||
isFirstRender.current = false;
|
||||
const type =
|
||||
Object.values(MWMediaType).find((v) => params.type === v) ||
|
||||
MWMediaType.MOVIE;
|
||||
const searchQuery = params.query || "";
|
||||
setSearch({ type, searchQuery });
|
||||
}, [setSearch, params, isFirstRender]);
|
||||
|
||||
return [search, updateParams, onUnFocus];
|
||||
}
|
||||
|
|
24
src/hooks/useVolumeToggle.ts
Normal file
24
src/hooks/useVolumeToggle.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useState } from "react";
|
||||
|
||||
export function useVolumeControl(descriptor: string) {
|
||||
const [storedVolume, setStoredVolume] = useState(1);
|
||||
const controls = useControls(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
||||
const toggleVolume = () => {
|
||||
if (mediaPlaying.volume > 0) {
|
||||
setStoredVolume(mediaPlaying.volume);
|
||||
controls.setVolume(0);
|
||||
} else {
|
||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
storedVolume,
|
||||
setStoredVolume,
|
||||
toggleVolume,
|
||||
};
|
||||
}
|
28
src/i18n.ts
28
src/i18n.ts
|
@ -1,28 +0,0 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import Backend from 'i18next-http-backend';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
i18n
|
||||
// load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales)
|
||||
// learn more: https://github.com/i18next/i18next-http-backend
|
||||
// want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn
|
||||
.use(Backend)
|
||||
// detect user language
|
||||
// learn more: https://github.com/i18next/i18next-browser-languageDetector
|
||||
.use(LanguageDetector)
|
||||
// pass the i18n instance to react-i18next.
|
||||
.use(initReactI18next)
|
||||
// init i18next
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({
|
||||
fallbackLng: 'en-GB',
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export default i18n;
|
|
@ -1,16 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-denim-100 text-denim-700 font-open-sans min-h-screen;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
|
@ -1,11 +1,15 @@
|
|||
import React, { Suspense } from "react";
|
||||
import React, { ReactNode, Suspense } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import { BrowserRouter, HashRouter } from "react-router-dom";
|
||||
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
||||
import App from "./App";
|
||||
import "./i18n";
|
||||
import { conf } from "./config";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
import App from "@/setup/App";
|
||||
import "@/setup/i18n";
|
||||
import "@/setup/index.css";
|
||||
import "@/backend";
|
||||
import { initializeChromecast } from "./setup/chromecast";
|
||||
import { initializeStores } from "./utils/storage";
|
||||
|
||||
// initialize
|
||||
const key =
|
||||
|
@ -13,15 +17,30 @@ const key =
|
|||
if (key) {
|
||||
(window as any).initMW(conf().BASE_PROXY_URL, key);
|
||||
}
|
||||
initializeChromecast();
|
||||
|
||||
const LazyLoadedApp = React.lazy(async () => {
|
||||
await initializeStores();
|
||||
return {
|
||||
default: App,
|
||||
};
|
||||
});
|
||||
|
||||
function TheRouter(props: { children: ReactNode }) {
|
||||
const normalRouter = conf().NORMAL_ROUTER;
|
||||
|
||||
if (normalRouter) return <BrowserRouter>{props.children}</BrowserRouter>;
|
||||
return <HashRouter>{props.children}</HashRouter>;
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<HashRouter>
|
||||
<TheRouter>
|
||||
<Suspense fallback="">
|
||||
<App />
|
||||
<LazyLoadedApp />
|
||||
</Suspense>
|
||||
</HashRouter>
|
||||
</TheRouter>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
# the providers
|
||||
|
||||
to make this as clear as possible, here is some extra information on how the interal system works regarding providers.
|
||||
|
||||
| Term | explanation |
|
||||
| ------------- | ------------------------------------------------------------------------------------- |
|
||||
| Media | Object containing information about a piece of media. like title and its id's |
|
||||
| PortableMedia | Object with just the identifiers of a piece of media. used for transport and saving |
|
||||
| MediaStream | Object with a stream url in it. use it to view a piece of media. |
|
||||
| Provider | group of methods to generate media and mediastreams from a source. aliased as scraper |
|
||||
|
||||
All types are prefixed with MW (MovieWeb) to prevent clashing names.
|
||||
|
||||
## Some rules
|
||||
|
||||
1. **Never** remove a provider completely if it's been in use before. just disable it.
|
||||
2. **Never** change the ID of a provider if it's been in use before.
|
||||
3. **Never** change system of the media ID of a provider without making it backwards compatible
|
||||
|
||||
All these rules are because `PortableMedia` objects need to stay functional. because:
|
||||
|
||||
- It's used for routing, links would stop working
|
||||
- It's used for storage, continue watching and bookmarks would stop working
|
||||
|
||||
# The list of providers and their quirks
|
||||
|
||||
Some providers have quirks, stuff they do differently than other providers
|
||||
|
||||
## TheFlix
|
||||
|
||||
- for series, the latest episode released will be one playing at first when you select it from search results
|
|
@ -1,42 +0,0 @@
|
|||
import { getProviderFromId } from "./methods/helpers";
|
||||
import { MWMedia, MWPortableMedia, MWMediaStream } from "./types";
|
||||
|
||||
export * from "./types";
|
||||
export * from "./methods/helpers";
|
||||
export * from "./methods/providers";
|
||||
export * from "./methods/search";
|
||||
|
||||
/*
|
||||
** Turn media object into a portable media object
|
||||
*/
|
||||
export function convertMediaToPortable(media: MWMedia): MWPortableMedia {
|
||||
return {
|
||||
mediaId: media.mediaId,
|
||||
providerId: media.providerId,
|
||||
mediaType: media.mediaType,
|
||||
episodeId: media.episodeId,
|
||||
seasonId: media.seasonId,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
** Turn portable media into media object
|
||||
*/
|
||||
export async function convertPortableToMedia(
|
||||
portable: MWPortableMedia
|
||||
): Promise<MWMedia | undefined> {
|
||||
const provider = getProviderFromId(portable.providerId);
|
||||
return provider?.getMediaFromPortable(portable);
|
||||
}
|
||||
|
||||
/*
|
||||
** find provider from portable and get stream from that provider
|
||||
*/
|
||||
export async function getStream(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWMediaStream | undefined> {
|
||||
const provider = getProviderFromId(media.providerId);
|
||||
if (!provider) return undefined;
|
||||
|
||||
return provider.getStream(media);
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWProviderMediaResult,
|
||||
} from "@/providers/types";
|
||||
|
||||
import { conf } from "@/config";
|
||||
|
||||
export const flixhqProvider: MWMediaProvider = {
|
||||
id: "flixhq",
|
||||
enabled: true,
|
||||
type: [MWMediaType.MOVIE],
|
||||
displayName: "flixhq",
|
||||
|
||||
async getMediaFromPortable(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWProviderMediaResult> {
|
||||
const searchRes = await fetch(
|
||||
`${
|
||||
conf().CORS_PROXY_URL
|
||||
}https://api.consumet.org/movies/flixhq/info?id=${encodeURIComponent(
|
||||
media.mediaId
|
||||
)}`
|
||||
).then((d) => d.json());
|
||||
|
||||
return {
|
||||
...media,
|
||||
title: searchRes.title,
|
||||
year: searchRes.releaseDate,
|
||||
} as MWProviderMediaResult;
|
||||
},
|
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
|
||||
const searchRes = await fetch(
|
||||
`${
|
||||
conf().CORS_PROXY_URL
|
||||
}https://api.consumet.org/movies/flixhq/${encodeURIComponent(
|
||||
query.searchQuery
|
||||
)}`
|
||||
).then((d) => d.json());
|
||||
|
||||
const results: MWProviderMediaResult[] = (searchRes || []).results.map(
|
||||
(item: any) => ({
|
||||
title: item.title,
|
||||
year: item.releaseDate,
|
||||
mediaId: item.id,
|
||||
type: MWMediaType.MOVIE,
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
||||
const searchRes = await fetch(
|
||||
`${
|
||||
conf().CORS_PROXY_URL
|
||||
}https://api.consumet.org/movies/flixhq/info?id=${encodeURIComponent(
|
||||
media.mediaId
|
||||
)}`
|
||||
).then((d) => d.json());
|
||||
|
||||
const params = new URLSearchParams({
|
||||
episodeId: searchRes.episodes[0].id,
|
||||
mediaId: media.mediaId,
|
||||
});
|
||||
|
||||
const watchRes = await fetch(
|
||||
`${
|
||||
conf().CORS_PROXY_URL
|
||||
}https://api.consumet.org/movies/flixhq/watch?${encodeURIComponent(
|
||||
params.toString()
|
||||
)}`
|
||||
).then((d) => d.json());
|
||||
|
||||
const source = watchRes.sources.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
|
||||
return {
|
||||
url: source.url,
|
||||
type: source.isM3U8 ? "m3u8" : "mp4",
|
||||
captions: [],
|
||||
} as MWMediaStream;
|
||||
},
|
||||
};
|
|
@ -1,125 +0,0 @@
|
|||
import { unpack } from "unpacker";
|
||||
import json5 from "json5";
|
||||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWProviderMediaResult,
|
||||
} from "@/providers/types";
|
||||
|
||||
import { conf } from "@/config";
|
||||
|
||||
export const gomostreamScraper: MWMediaProvider = {
|
||||
id: "gomostream",
|
||||
enabled: true,
|
||||
type: [MWMediaType.MOVIE],
|
||||
displayName: "gomostream",
|
||||
|
||||
async getMediaFromPortable(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWProviderMediaResult> {
|
||||
const params = new URLSearchParams({
|
||||
apikey: conf().OMDB_API_KEY,
|
||||
i: media.mediaId,
|
||||
type: media.mediaType,
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${conf().CORS_PROXY_URL}http://www.omdbapi.com/?${encodeURIComponent(
|
||||
params.toString()
|
||||
)}`
|
||||
).then((d) => d.json());
|
||||
|
||||
return {
|
||||
...media,
|
||||
title: res.Title,
|
||||
year: res.Year,
|
||||
} as MWProviderMediaResult;
|
||||
},
|
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
|
||||
const term = query.searchQuery.toLowerCase();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
apikey: conf().OMDB_API_KEY,
|
||||
s: term,
|
||||
type: query.type,
|
||||
});
|
||||
const searchRes = await fetch(
|
||||
`${conf().CORS_PROXY_URL}http://www.omdbapi.com/?${encodeURIComponent(
|
||||
params.toString()
|
||||
)}`
|
||||
).then((d) => d.json());
|
||||
|
||||
const results: MWProviderMediaResult[] = (searchRes.Search || []).map(
|
||||
(d: any) =>
|
||||
({
|
||||
title: d.Title,
|
||||
year: d.Year,
|
||||
mediaId: d.imdbID,
|
||||
} as MWProviderMediaResult)
|
||||
);
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
||||
const type =
|
||||
media.mediaType === MWMediaType.SERIES ? "show" : media.mediaType;
|
||||
const res1 = await fetch(
|
||||
`${conf().CORS_PROXY_URL}https://gomo.to/${type}/${media.mediaId}`
|
||||
).then((d) => d.text());
|
||||
if (res1 === "Movie not available." || res1 === "Episode not available.")
|
||||
throw new Error(res1);
|
||||
|
||||
const tc = res1.match(/var tc = '(.+)';/)?.[1] || "";
|
||||
const _token = res1.match(/"_token": "(.+)",/)?.[1] || "";
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("tokenCode", tc);
|
||||
fd.append("_token", _token);
|
||||
|
||||
const src = await fetch(
|
||||
`${conf().CORS_PROXY_URL}https://gomo.to/decoding_v3.php`,
|
||||
{
|
||||
method: "POST",
|
||||
body: fd,
|
||||
headers: {
|
||||
"x-token": `${tc.slice(5, 13).split("").reverse().join("")}13574199`,
|
||||
},
|
||||
}
|
||||
).then((d) => d.json());
|
||||
const embeds = src.filter((url: string) => url.includes("gomo.to"));
|
||||
|
||||
// maybe try all embeds in the future
|
||||
const embedUrl = embeds[1];
|
||||
const res2 = await fetch(`${conf().CORS_PROXY_URL}${embedUrl}`).then((d) =>
|
||||
d.text()
|
||||
);
|
||||
|
||||
const res2DOM = new DOMParser().parseFromString(res2, "text/html");
|
||||
if (res2DOM.body.innerText === "File was deleted")
|
||||
throw new Error("File was deleted");
|
||||
|
||||
const script = Array.from(res2DOM.querySelectorAll("script")).find(
|
||||
(s: HTMLScriptElement) =>
|
||||
s.innerHTML.includes("eval(function(p,a,c,k,e,d")
|
||||
)?.innerHTML;
|
||||
if (!script) throw new Error("Could not get packed data");
|
||||
|
||||
const unpacked = unpack(script);
|
||||
const rawSources = /sources:(\[.*?\])/.exec(unpacked);
|
||||
if (!rawSources) throw new Error("Could not get rawSources");
|
||||
|
||||
const sources = json5.parse(rawSources[1]);
|
||||
const streamUrl = sources[0].file;
|
||||
|
||||
const streamType = streamUrl.split(".").at(-1);
|
||||
if (streamType !== "mp4" && streamType !== "m3u8")
|
||||
throw new Error("Unsupported stream type");
|
||||
|
||||
return { url: streamUrl, type: streamType, captions: [] };
|
||||
},
|
||||
};
|
|
@ -1,307 +0,0 @@
|
|||
// this is derived from https://github.com/recloudstream/cloudstream-extensions
|
||||
// for more info please check the LICENSE file in the same directory
|
||||
|
||||
import { customAlphabet } from "nanoid";
|
||||
import toWebVTT from "srt-webvtt";
|
||||
import CryptoJS from "crypto-js";
|
||||
import { conf } from "@/config";
|
||||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWMediaSeasons,
|
||||
MWProviderMediaResult,
|
||||
} from "@/providers/types";
|
||||
|
||||
const nanoid = customAlphabet("0123456789abcdef", 32);
|
||||
|
||||
// CONSTANTS, read below (taken from og)
|
||||
// We do not want content scanners to notice this scraping going on so we've hidden all constants
|
||||
// The source has its origins in China so I added some extra security with banned words
|
||||
// Mayhaps a tiny bit unethical, but this source is just too good :)
|
||||
// If you are copying this code please use precautions so they do not change their api.
|
||||
const iv = atob("d0VpcGhUbiE=");
|
||||
const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2");
|
||||
const apiUrls = [
|
||||
atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="),
|
||||
atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="),
|
||||
];
|
||||
const appKey = atob("bW92aWVib3g=");
|
||||
const appId = atob("Y29tLnRkby5zaG93Ym94");
|
||||
|
||||
// cryptography stuff
|
||||
const crypto = {
|
||||
encrypt(str: string) {
|
||||
return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), {
|
||||
iv: CryptoJS.enc.Utf8.parse(iv),
|
||||
}).toString();
|
||||
},
|
||||
getVerify(str: string, str2: string, str3: string) {
|
||||
if (str) {
|
||||
return CryptoJS.MD5(
|
||||
CryptoJS.MD5(str2).toString() + str3 + str
|
||||
).toString();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
// get expire time
|
||||
const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12);
|
||||
|
||||
// sending requests
|
||||
const get = (data: object, altApi = false) => {
|
||||
const defaultData = {
|
||||
childmode: "0",
|
||||
app_version: "11.5",
|
||||
appid: appId,
|
||||
lang: "en",
|
||||
expired_date: `${expiry()}`,
|
||||
platform: "android",
|
||||
channel: "Website",
|
||||
};
|
||||
const encryptedData = crypto.encrypt(
|
||||
JSON.stringify({
|
||||
...defaultData,
|
||||
...data,
|
||||
})
|
||||
);
|
||||
const appKeyHash = CryptoJS.MD5(appKey).toString();
|
||||
const verify = crypto.getVerify(encryptedData, appKey, key);
|
||||
const body = JSON.stringify({
|
||||
app_key: appKeyHash,
|
||||
verify,
|
||||
encrypt_data: encryptedData,
|
||||
});
|
||||
const b64Body = btoa(body);
|
||||
|
||||
const formatted = new URLSearchParams();
|
||||
formatted.append("data", b64Body);
|
||||
formatted.append("appid", "27");
|
||||
formatted.append("platform", "android");
|
||||
formatted.append("version", "129");
|
||||
formatted.append("medium", "Website");
|
||||
|
||||
const requestUrl = altApi ? apiUrls[1] : apiUrls[0];
|
||||
return fetch(`${conf().CORS_PROXY_URL}${requestUrl}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Platform: "android",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `${formatted.toString()}&token${nanoid()}`,
|
||||
});
|
||||
};
|
||||
|
||||
export const superStreamScraper: MWMediaProvider = {
|
||||
id: "superstream",
|
||||
enabled: true,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
displayName: "SuperStream",
|
||||
|
||||
async getMediaFromPortable(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWProviderMediaResult> {
|
||||
let apiQuery: any;
|
||||
if (media.mediaType === MWMediaType.SERIES) {
|
||||
apiQuery = {
|
||||
module: "TV_detail_1",
|
||||
display_all: "1",
|
||||
tid: media.mediaId,
|
||||
};
|
||||
} else {
|
||||
apiQuery = {
|
||||
module: "Movie_detail",
|
||||
mid: media.mediaId,
|
||||
};
|
||||
}
|
||||
const detailRes = (await get(apiQuery, true).then((r) => r.json())).data;
|
||||
|
||||
return {
|
||||
...media,
|
||||
title: detailRes.title,
|
||||
year: detailRes.year,
|
||||
seasonCount: detailRes?.season?.length,
|
||||
} as MWProviderMediaResult;
|
||||
},
|
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
|
||||
const apiQuery = {
|
||||
module: "Search3",
|
||||
page: "1",
|
||||
type: "all",
|
||||
keyword: query.searchQuery,
|
||||
pagelimit: "20",
|
||||
};
|
||||
const searchRes = (await get(apiQuery, true).then((r) => r.json())).data;
|
||||
|
||||
const movieResults: MWProviderMediaResult[] = (searchRes || [])
|
||||
.filter((item: any) => item.box_type === 1)
|
||||
.map((item: any) => ({
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
mediaId: item.id,
|
||||
}));
|
||||
const seriesResults: MWProviderMediaResult[] = (searchRes || [])
|
||||
.filter((item: any) => item.box_type === 2)
|
||||
.map((item: any) => ({
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
mediaId: item.id,
|
||||
seasonId: "1",
|
||||
episodeId: "1",
|
||||
}));
|
||||
|
||||
if (query.type === MWMediaType.MOVIE) {
|
||||
return movieResults;
|
||||
}
|
||||
if (query.type === MWMediaType.SERIES) {
|
||||
return seriesResults;
|
||||
}
|
||||
throw new Error("Invalid media type used.");
|
||||
},
|
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
||||
if (media.mediaType === MWMediaType.MOVIE) {
|
||||
const apiQuery = {
|
||||
uid: "",
|
||||
module: "Movie_downloadurl_v3",
|
||||
mid: media.mediaId,
|
||||
oss: "1",
|
||||
group: "",
|
||||
};
|
||||
const mediaRes = (await get(apiQuery).then((r) => r.json())).data;
|
||||
const hdQuality =
|
||||
mediaRes.list.find(
|
||||
(quality: any) => quality.quality === "1080p" && quality.path
|
||||
) ??
|
||||
mediaRes.list.find(
|
||||
(quality: any) => quality.quality === "720p" && quality.path
|
||||
) ??
|
||||
mediaRes.list.find(
|
||||
(quality: any) => quality.quality === "480p" && quality.path
|
||||
) ??
|
||||
mediaRes.list.find(
|
||||
(quality: any) => quality.quality === "360p" && quality.path
|
||||
);
|
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found.");
|
||||
|
||||
const subtitleApiQuery = {
|
||||
fid: hdQuality.fid,
|
||||
uid: "",
|
||||
module: "Movie_srt_list_v2",
|
||||
mid: media.mediaId,
|
||||
};
|
||||
const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json()))
|
||||
.data;
|
||||
const mappedCaptions = await Promise.all(
|
||||
subtitleRes.list.map(async (subtitle: any) => {
|
||||
const captionBlob = await fetch(
|
||||
`${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}`
|
||||
).then((captionRes) => captionRes.blob()); // cross-origin bypass
|
||||
const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable
|
||||
return {
|
||||
id: subtitle.language,
|
||||
url: captionUrl,
|
||||
label: subtitle.language,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return { url: hdQuality.path, type: "mp4", captions: mappedCaptions };
|
||||
}
|
||||
|
||||
const apiQuery = {
|
||||
uid: "",
|
||||
module: "TV_downloadurl_v3",
|
||||
episode: media.episodeId,
|
||||
tid: media.mediaId,
|
||||
season: media.seasonId,
|
||||
oss: "1",
|
||||
group: "",
|
||||
};
|
||||
const mediaRes = (await get(apiQuery).then((r) => r.json())).data;
|
||||
const hdQuality =
|
||||
mediaRes.list.find(
|
||||
(quality: any) => quality.quality === "1080p" && quality.path
|
||||
) ??
|
||||
mediaRes.list.find(
|
||||
(quality: any) => quality.quality === "720p" && quality.path
|
||||
) ??
|
||||
mediaRes.list.find(
|
||||
(quality: any) => quality.quality === "480p" && quality.path
|
||||
) ??
|
||||
mediaRes.list.find(
|
||||
(quality: any) => quality.quality === "360p" && quality.path
|
||||
);
|
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found.");
|
||||
|
||||
const subtitleApiQuery = {
|
||||
fid: hdQuality.fid,
|
||||
uid: "",
|
||||
module: "TV_srt_list_v2",
|
||||
episode: media.episodeId,
|
||||
tid: media.mediaId,
|
||||
season: media.seasonId,
|
||||
};
|
||||
const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json()))
|
||||
.data;
|
||||
const mappedCaptions = await Promise.all(
|
||||
subtitleRes.list.map(async (subtitle: any) => {
|
||||
const captionBlob = await fetch(
|
||||
`${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}`
|
||||
).then((captionRes) => captionRes.blob()); // cross-origin bypass
|
||||
const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable
|
||||
return {
|
||||
id: subtitle.language,
|
||||
url: captionUrl,
|
||||
label: subtitle.language,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return { url: hdQuality.path, type: "mp4", captions: mappedCaptions };
|
||||
},
|
||||
async getSeasonDataFromMedia(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWMediaSeasons> {
|
||||
const apiQuery = {
|
||||
module: "TV_detail_1",
|
||||
display_all: "1",
|
||||
tid: media.mediaId,
|
||||
};
|
||||
const detailRes = (await get(apiQuery, true).then((r) => r.json())).data;
|
||||
const firstSearchResult = (
|
||||
await fetch(
|
||||
`https://api.themoviedb.org/3/search/tv?api_key=${
|
||||
conf().TMDB_API_KEY
|
||||
}&language=en-US&page=1&query=${detailRes.title}&include_adult=false`
|
||||
).then((r) => r.json())
|
||||
).results[0];
|
||||
const showDetails = await fetch(
|
||||
`https://api.themoviedb.org/3/tv/${firstSearchResult.id}?api_key=${
|
||||
conf().TMDB_API_KEY
|
||||
}`
|
||||
).then((r) => r.json());
|
||||
|
||||
return {
|
||||
seasons: showDetails.seasons.map((season: any) => ({
|
||||
sort: season.season_number,
|
||||
id: season.season_number.toString(),
|
||||
type: season.season_number === 0 ? "special" : "season",
|
||||
episodes: Array.from({ length: season.episode_count }).map(
|
||||
(_, epNum) => ({
|
||||
title: `Episode ${epNum + 1}`,
|
||||
sort: epNum + 1,
|
||||
id: (epNum + 1).toString(),
|
||||
episodeNumber: epNum + 1,
|
||||
})
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
|
@ -1,119 +0,0 @@
|
|||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWMediaSeasons,
|
||||
MWProviderMediaResult,
|
||||
} from "@/providers/types";
|
||||
|
||||
import {
|
||||
searchTheFlix,
|
||||
getDataFromSearch,
|
||||
turnDataIntoMedia,
|
||||
} from "@/providers/list/theflix/search";
|
||||
|
||||
import { getDataFromPortableSearch } from "@/providers/list/theflix/portableToMedia";
|
||||
import { conf } from "@/config";
|
||||
|
||||
export const theFlixScraper: MWMediaProvider = {
|
||||
id: "theflix",
|
||||
enabled: false,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
displayName: "theflix",
|
||||
|
||||
async getMediaFromPortable(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWProviderMediaResult> {
|
||||
const data: any = await getDataFromPortableSearch(media);
|
||||
|
||||
return {
|
||||
...media,
|
||||
year: new Date(data.releaseDate).getFullYear().toString(),
|
||||
title: data.name,
|
||||
};
|
||||
},
|
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
|
||||
const searchRes = await searchTheFlix(query);
|
||||
const searchData = await getDataFromSearch(searchRes, 10);
|
||||
|
||||
const results: MWProviderMediaResult[] = [];
|
||||
for (const item of searchData) {
|
||||
results.push(turnDataIntoMedia(item));
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
||||
let url = "";
|
||||
|
||||
if (media.mediaType === MWMediaType.MOVIE) {
|
||||
url = `${conf().CORS_PROXY_URL}https://theflix.to/movie/${
|
||||
media.mediaId
|
||||
}?movieInfo=${media.mediaId}`;
|
||||
} else if (media.mediaType === MWMediaType.SERIES) {
|
||||
url = `${conf().CORS_PROXY_URL}https://theflix.to/tv-show/${
|
||||
media.mediaId
|
||||
}/season-${media.seasonId}/episode-${media.episodeId}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url).then((d) => d.text());
|
||||
|
||||
const prop: HTMLElement | undefined = Array.from(
|
||||
new DOMParser()
|
||||
.parseFromString(res, "text/html")
|
||||
.querySelectorAll("script")
|
||||
).find((e) => e.textContent?.includes("theflixvd.b-cdn"));
|
||||
|
||||
if (!prop || !prop.textContent) {
|
||||
throw new Error("Could not find stream");
|
||||
}
|
||||
|
||||
const data = JSON.parse(prop.textContent);
|
||||
|
||||
return { url: data.props.pageProps.videoUrl, type: "mp4", captions: [] };
|
||||
},
|
||||
|
||||
async getSeasonDataFromMedia(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWMediaSeasons> {
|
||||
const url = `${conf().CORS_PROXY_URL}https://theflix.to/tv-show/${
|
||||
media.mediaId
|
||||
}/season-${media.seasonId}/episode-${media.episodeId}`;
|
||||
const res = await fetch(url).then((d) => d.text());
|
||||
|
||||
const node: Element = Array.from(
|
||||
new DOMParser()
|
||||
.parseFromString(res, "text/html")
|
||||
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
|
||||
)[0];
|
||||
|
||||
let data = JSON.parse(node.innerHTML).props.pageProps.selectedTv.seasons;
|
||||
|
||||
data = data.filter((season: any) => season.releaseDate != null);
|
||||
data = data.map((season: any) => {
|
||||
const episodes = season.episodes.filter(
|
||||
(episode: any) => episode.releaseDate != null
|
||||
);
|
||||
return { ...season, episodes };
|
||||
});
|
||||
|
||||
return {
|
||||
seasons: data.map((d: any) => ({
|
||||
sort: d.seasonNumber === 0 ? 999 : d.seasonNumber,
|
||||
id: d.seasonNumber.toString(),
|
||||
type: d.seasonNumber === 0 ? "special" : "season",
|
||||
title: d.name,
|
||||
episodes: d.episodes.map((e: any) => ({
|
||||
title: e.name,
|
||||
sort: e.episodeNumber,
|
||||
id: e.episodeNumber.toString(),
|
||||
episodeNumber: e.episodeNumber,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
|
@ -1,36 +0,0 @@
|
|||
import { conf } from "@/config";
|
||||
import { MWMediaType, MWPortableMedia } from "@/providers/types";
|
||||
|
||||
const getTheFlixUrl = (media: MWPortableMedia, params?: URLSearchParams) => {
|
||||
if (media.mediaType === MWMediaType.MOVIE) {
|
||||
return `https://theflix.to/movie/${media.mediaId}?${params}`;
|
||||
}
|
||||
if (media.mediaType === MWMediaType.SERIES) {
|
||||
return `https://theflix.to/tv-show/${media.mediaId}/season-${media.seasonId}/episode-${media.episodeId}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
export async function getDataFromPortableSearch(
|
||||
media: MWPortableMedia
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("movieInfo", media.mediaId);
|
||||
|
||||
const res = await fetch(
|
||||
conf().CORS_PROXY_URL + getTheFlixUrl(media, params)
|
||||
).then((d) => d.text());
|
||||
|
||||
const node: Element = Array.from(
|
||||
new DOMParser()
|
||||
.parseFromString(res, "text/html")
|
||||
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
|
||||
)[0];
|
||||
|
||||
if (media.mediaType === MWMediaType.MOVIE) {
|
||||
return JSON.parse(node.innerHTML).props.pageProps.movie;
|
||||
}
|
||||
// must be series here
|
||||
return JSON.parse(node.innerHTML).props.pageProps.selectedTv;
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import { conf } from "@/config";
|
||||
import { MWMediaType, MWProviderMediaResult, MWQuery } from "@/providers";
|
||||
|
||||
const getTheFlixUrl = (type: "tv-shows" | "movies", params: URLSearchParams) =>
|
||||
`https://theflix.to/${type}/trending?${params}`;
|
||||
|
||||
export function searchTheFlix(query: MWQuery): Promise<string> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("search", query.searchQuery);
|
||||
return fetch(
|
||||
conf().CORS_PROXY_URL +
|
||||
getTheFlixUrl(
|
||||
query.type === MWMediaType.MOVIE ? "movies" : "tv-shows",
|
||||
params
|
||||
)
|
||||
).then((d) => d.text());
|
||||
}
|
||||
|
||||
export function getDataFromSearch(page: string, limit = 10): any[] {
|
||||
const node: Element = Array.from(
|
||||
new DOMParser()
|
||||
.parseFromString(page, "text/html")
|
||||
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
|
||||
)[0];
|
||||
const data = JSON.parse(node.innerHTML);
|
||||
return data.props.pageProps.mainList.docs
|
||||
.filter((d: any) => d.available)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function turnDataIntoMedia(data: any): MWProviderMediaResult {
|
||||
return {
|
||||
mediaId: `${data.id}-${data.name
|
||||
.replace(/[^a-z0-9]+|\s+/gim, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase()}`,
|
||||
title: data.name,
|
||||
year: new Date(data.releaseDate).getFullYear().toString(),
|
||||
seasonCount: data.numberOfSeasons,
|
||||
episodeId: data.lastReleasedEpisode
|
||||
? data.lastReleasedEpisode.episodeNumber.toString()
|
||||
: null,
|
||||
seasonId: data.lastReleasedEpisode
|
||||
? data.lastReleasedEpisode.seasonNumber.toString()
|
||||
: null,
|
||||
};
|
||||
}
|
|
@ -1,142 +0,0 @@
|
|||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWProviderMediaResult,
|
||||
MWMediaCaption,
|
||||
} from "@/providers/types";
|
||||
|
||||
import { conf } from "@/config";
|
||||
|
||||
export const xemovieScraper: MWMediaProvider = {
|
||||
id: "xemovie",
|
||||
enabled: false,
|
||||
type: [MWMediaType.MOVIE],
|
||||
displayName: "xemovie",
|
||||
|
||||
async getMediaFromPortable(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWProviderMediaResult> {
|
||||
const res = await fetch(
|
||||
`${conf().CORS_PROXY_URL}https://xemovie.co/movies/${media.mediaId}/watch`
|
||||
).then((d) => d.text());
|
||||
|
||||
const DOM = new DOMParser().parseFromString(res, "text/html");
|
||||
|
||||
const title =
|
||||
DOM.querySelector(".text-primary.text-lg.font-extrabold")?.textContent ||
|
||||
"";
|
||||
const year =
|
||||
DOM.querySelector("div.justify-between:nth-child(3) > div:nth-child(2)")
|
||||
?.textContent || "";
|
||||
|
||||
return {
|
||||
...media,
|
||||
title,
|
||||
year,
|
||||
} as MWProviderMediaResult;
|
||||
},
|
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
|
||||
const term = query.searchQuery.toLowerCase();
|
||||
|
||||
const searchUrl = `${
|
||||
conf().CORS_PROXY_URL
|
||||
}https://xemovie.co/search?q=${encodeURIComponent(term)}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text());
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(searchRes, "text/html");
|
||||
|
||||
const movieContainer = doc
|
||||
.querySelectorAll(".py-10")[0]
|
||||
.querySelector(".grid");
|
||||
if (!movieContainer) return [];
|
||||
const movieNodes = Array.from(movieContainer.querySelectorAll("a")).filter(
|
||||
(link) => !link.className
|
||||
);
|
||||
|
||||
const results: MWProviderMediaResult[] = movieNodes
|
||||
.map((node) => {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const aElement = parent.querySelector("a");
|
||||
if (!aElement) return;
|
||||
|
||||
return {
|
||||
title: parent.querySelector("div > div > a > h6")?.textContent,
|
||||
year: parent.querySelector("div.float-right")?.textContent,
|
||||
mediaId: aElement.href.split("/").pop() || "",
|
||||
};
|
||||
})
|
||||
.filter((d): d is MWProviderMediaResult => !!d);
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
||||
if (media.mediaType !== MWMediaType.MOVIE)
|
||||
throw new Error("Incorrect type");
|
||||
|
||||
const url = `${conf().CORS_PROXY_URL}https://xemovie.co/movies/${
|
||||
media.mediaId
|
||||
}/watch`;
|
||||
|
||||
let streamUrl = "";
|
||||
const subtitles: MWMediaCaption[] = [];
|
||||
|
||||
const res = await fetch(url).then((d) => d.text());
|
||||
const scripts = Array.from(
|
||||
new DOMParser()
|
||||
.parseFromString(res, "text/html")
|
||||
.querySelectorAll("script")
|
||||
);
|
||||
|
||||
for (const script of scripts) {
|
||||
if (!script.textContent) continue;
|
||||
|
||||
if (script.textContent.match(/https:\/\/[a-z][0-9]\.xemovie\.com/)) {
|
||||
const data = JSON.parse(
|
||||
JSON.stringify(
|
||||
eval(
|
||||
`(${
|
||||
script.textContent.replace("const data = ", "").split("};")[0]
|
||||
}})`
|
||||
)
|
||||
)
|
||||
);
|
||||
streamUrl = data.playlist[0].file;
|
||||
|
||||
for (const [
|
||||
index,
|
||||
subtitleTrack,
|
||||
] of data.playlist[0].tracks.entries()) {
|
||||
const subtitleBlob = URL.createObjectURL(
|
||||
await fetch(`${conf().CORS_PROXY_URL}${subtitleTrack.file}`).then(
|
||||
(captionRes) => captionRes.blob()
|
||||
)
|
||||
); // do this so no need for CORS errors
|
||||
|
||||
subtitles.push({
|
||||
id: index,
|
||||
url: subtitleBlob,
|
||||
label: subtitleTrack.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const streamType = streamUrl.split(".").at(-1);
|
||||
if (streamType !== "mp4" && streamType !== "m3u8")
|
||||
throw new Error("Unsupported stream type");
|
||||
|
||||
return {
|
||||
url: streamUrl,
|
||||
type: streamType,
|
||||
captions: subtitles,
|
||||
} as MWMediaStream;
|
||||
},
|
||||
};
|
|
@ -1,11 +0,0 @@
|
|||
import { SimpleCache } from "@/utils/cache";
|
||||
import { MWPortableMedia, MWMedia } from "@/providers";
|
||||
|
||||
// cache
|
||||
const contentCache = new SimpleCache<MWPortableMedia, MWMedia>();
|
||||
contentCache.setCompare(
|
||||
(a, b) => a.mediaId === b.mediaId && a.providerId === b.providerId
|
||||
);
|
||||
contentCache.initialize();
|
||||
|
||||
export default contentCache;
|
|
@ -1,65 +0,0 @@
|
|||
import { MWMediaType, MWMediaProviderMetadata } from "@/providers";
|
||||
import { MWMedia, MWMediaEpisode, MWMediaSeason } from "@/providers/types";
|
||||
import { mediaProviders, mediaProvidersUnchecked } from "./providers";
|
||||
|
||||
/*
|
||||
** Fetch all enabled providers for a specific type
|
||||
*/
|
||||
export function GetProvidersForType(type: MWMediaType) {
|
||||
return mediaProviders.filter((v) => v.type.includes(type));
|
||||
}
|
||||
|
||||
/*
|
||||
** Get a provider by a id
|
||||
*/
|
||||
export function getProviderFromId(id: string) {
|
||||
return mediaProviders.find((v) => v.id === id);
|
||||
}
|
||||
|
||||
/*
|
||||
** Get a provider metadata
|
||||
*/
|
||||
export function getProviderMetadata(id: string): MWMediaProviderMetadata {
|
||||
const provider = mediaProvidersUnchecked.find((v) => v.id === id);
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
exists: false,
|
||||
type: [],
|
||||
enabled: false,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
type: provider.type,
|
||||
enabled: provider.enabled,
|
||||
id,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
** get episode and season from media
|
||||
*/
|
||||
export function getEpisodeFromMedia(
|
||||
media: MWMedia
|
||||
): { season: MWMediaSeason; episode: MWMediaEpisode } | null {
|
||||
if (
|
||||
media.seasonId === undefined ||
|
||||
media.episodeId === undefined ||
|
||||
media.seriesData === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const season = media.seriesData.seasons.find((v) => v.id === media.seasonId);
|
||||
if (!season) return null;
|
||||
const episode = season?.episodes.find((v) => v.id === media.episodeId);
|
||||
if (!episode) return null;
|
||||
return {
|
||||
season,
|
||||
episode,
|
||||
};
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
import { theFlixScraper } from "@/providers/list/theflix";
|
||||
import { gDrivePlayerScraper } from "@/providers/list/gdriveplayer";
|
||||
import { MWWrappedMediaProvider, WrapProvider } from "@/providers/wrapper";
|
||||
import { gomostreamScraper } from "@/providers/list/gomostream";
|
||||
import { xemovieScraper } from "@/providers/list/xemovie";
|
||||
import { flixhqProvider } from "@/providers/list/flixhq";
|
||||
import { superStreamScraper } from "@/providers/list/superstream";
|
||||
|
||||
export const mediaProvidersUnchecked: MWWrappedMediaProvider[] = [
|
||||
WrapProvider(superStreamScraper),
|
||||
WrapProvider(theFlixScraper),
|
||||
WrapProvider(gDrivePlayerScraper),
|
||||
WrapProvider(gomostreamScraper),
|
||||
WrapProvider(xemovieScraper),
|
||||
WrapProvider(flixhqProvider),
|
||||
];
|
||||
|
||||
export const mediaProviders: MWWrappedMediaProvider[] =
|
||||
mediaProvidersUnchecked.filter((v) => v.enabled);
|
|
@ -1,105 +0,0 @@
|
|||
import Fuse from "fuse.js";
|
||||
import {
|
||||
MWMassProviderOutput,
|
||||
MWMedia,
|
||||
MWQuery,
|
||||
convertMediaToPortable,
|
||||
} from "@/providers";
|
||||
import { SimpleCache } from "@/utils/cache";
|
||||
import { GetProvidersForType } from "./helpers";
|
||||
import contentCache from "./contentCache";
|
||||
|
||||
// cache
|
||||
const resultCache = new SimpleCache<MWQuery, MWMassProviderOutput>();
|
||||
resultCache.setCompare(
|
||||
(a, b) => a.searchQuery === b.searchQuery && a.type === b.type
|
||||
);
|
||||
resultCache.initialize();
|
||||
|
||||
/*
|
||||
** actually call all providers with the search query
|
||||
*/
|
||||
async function callProviders(query: MWQuery): Promise<MWMassProviderOutput> {
|
||||
const allQueries = GetProvidersForType(query.type).map<
|
||||
Promise<{ media: MWMedia[]; success: boolean; id: string }>
|
||||
>(async (provider) => {
|
||||
try {
|
||||
return {
|
||||
media: await provider.searchForMedia(query),
|
||||
success: true,
|
||||
id: provider.id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Failed running provider ${provider.id}`, err, query);
|
||||
return {
|
||||
media: [],
|
||||
success: false,
|
||||
id: provider.id,
|
||||
};
|
||||
}
|
||||
});
|
||||
const allResults = await Promise.all(allQueries);
|
||||
const providerResults = allResults.map((provider) => ({
|
||||
success: provider.success,
|
||||
id: provider.id,
|
||||
}));
|
||||
const output: MWMassProviderOutput = {
|
||||
results: allResults.flatMap((results) => results.media),
|
||||
providers: providerResults,
|
||||
stats: {
|
||||
total: providerResults.length,
|
||||
failed: providerResults.filter((v) => !v.success).length,
|
||||
succeeded: providerResults.filter((v) => v.success).length,
|
||||
},
|
||||
};
|
||||
|
||||
// save in cache if all successfull
|
||||
if (output.stats.failed === 0) {
|
||||
resultCache.set(query, output, 60 * 60); // cache for an hour
|
||||
}
|
||||
|
||||
output.results.forEach((result: MWMedia) => {
|
||||
contentCache.set(convertMediaToPortable(result), result, 60 * 60);
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/*
|
||||
** sort results based on query
|
||||
*/
|
||||
function sortResults(
|
||||
query: MWQuery,
|
||||
providerResults: MWMassProviderOutput
|
||||
): MWMassProviderOutput {
|
||||
const results: MWMassProviderOutput = { ...providerResults };
|
||||
const fuse = new Fuse(results.results, {
|
||||
threshold: 0.3,
|
||||
keys: ["title"],
|
||||
fieldNormWeight: 0.5,
|
||||
});
|
||||
results.results = fuse.search(query.searchQuery).map((v) => v.item);
|
||||
return results;
|
||||
}
|
||||
|
||||
/*
|
||||
** Call search on all providers that matches query type
|
||||
*/
|
||||
export async function SearchProviders(
|
||||
inputQuery: MWQuery
|
||||
): Promise<MWMassProviderOutput> {
|
||||
// input normalisation
|
||||
const query = { ...inputQuery };
|
||||
query.searchQuery = query.searchQuery.toLowerCase().trim();
|
||||
|
||||
// consult cache first
|
||||
let output = resultCache.get(query);
|
||||
if (!output) output = await callProviders(query);
|
||||
|
||||
// sort results
|
||||
output = sortResults(query, output);
|
||||
|
||||
if (output.stats.total === output.stats.failed)
|
||||
throw new Error("All Scrapers failed");
|
||||
return output;
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
import { SimpleCache } from "@/utils/cache";
|
||||
import { MWPortableMedia } from "@/providers";
|
||||
import {
|
||||
MWMediaSeasons,
|
||||
MWMediaType,
|
||||
MWMediaProviderSeries,
|
||||
} from "@/providers/types";
|
||||
import { getProviderFromId } from "./helpers";
|
||||
|
||||
// cache
|
||||
const seasonCache = new SimpleCache<MWPortableMedia, MWMediaSeasons>();
|
||||
seasonCache.setCompare(
|
||||
(a, b) => a.mediaId === b.mediaId && a.providerId === b.providerId
|
||||
);
|
||||
seasonCache.initialize();
|
||||
|
||||
/*
|
||||
** get season data from a (portable) media object, seasons and episodes will be sorted
|
||||
*/
|
||||
export async function getSeasonDataFromMedia(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWMediaSeasons> {
|
||||
const provider = getProviderFromId(media.providerId) as MWMediaProviderSeries;
|
||||
if (!provider) {
|
||||
return {
|
||||
seasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!provider.type.includes(MWMediaType.SERIES) &&
|
||||
!provider.type.includes(MWMediaType.ANIME)
|
||||
) {
|
||||
return {
|
||||
seasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (seasonCache.has(media)) {
|
||||
return seasonCache.get(media) as MWMediaSeasons;
|
||||
}
|
||||
|
||||
const seasonData = await provider.getSeasonDataFromMedia(media);
|
||||
seasonData.seasons.sort((a, b) => a.sort - b.sort);
|
||||
seasonData.seasons.forEach((s) => s.episodes.sort((a, b) => a.sort - b.sort));
|
||||
|
||||
// cache it
|
||||
seasonCache.set(media, seasonData, 60 * 60); // cache it for an hour
|
||||
return seasonData;
|
||||
}
|
|
@ -1,97 +0,0 @@
|
|||
export enum MWMediaType {
|
||||
MOVIE = "movie",
|
||||
SERIES = "series",
|
||||
ANIME = "anime",
|
||||
}
|
||||
|
||||
export interface MWPortableMedia {
|
||||
mediaId: string;
|
||||
mediaType: MWMediaType;
|
||||
providerId: string;
|
||||
seasonId?: string;
|
||||
episodeId?: string;
|
||||
}
|
||||
|
||||
export type MWMediaStreamType = "m3u8" | "mp4";
|
||||
export interface MWMediaCaption {
|
||||
id: string;
|
||||
url: string;
|
||||
label: string;
|
||||
}
|
||||
export interface MWMediaStream {
|
||||
url: string;
|
||||
type: MWMediaStreamType;
|
||||
captions: MWMediaCaption[];
|
||||
}
|
||||
|
||||
export interface MWMediaMeta extends MWPortableMedia {
|
||||
title: string;
|
||||
year: string;
|
||||
seasonCount?: number;
|
||||
}
|
||||
|
||||
export interface MWMediaEpisode {
|
||||
sort: number;
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
export interface MWMediaSeason {
|
||||
sort: number;
|
||||
id: string;
|
||||
title?: string;
|
||||
type: "season" | "special";
|
||||
episodes: MWMediaEpisode[];
|
||||
}
|
||||
export interface MWMediaSeasons {
|
||||
seasons: MWMediaSeason[];
|
||||
}
|
||||
|
||||
export interface MWMedia extends MWMediaMeta {
|
||||
seriesData?: MWMediaSeasons;
|
||||
}
|
||||
|
||||
export type MWProviderMediaResult = Omit<MWMedia, "mediaType" | "providerId">;
|
||||
|
||||
export interface MWQuery {
|
||||
searchQuery: string;
|
||||
type: MWMediaType;
|
||||
}
|
||||
|
||||
export interface MWMediaProviderBase {
|
||||
id: string; // id of provider, must be unique
|
||||
enabled: boolean;
|
||||
type: MWMediaType[];
|
||||
displayName: string;
|
||||
|
||||
getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult>;
|
||||
searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]>;
|
||||
getStream(media: MWPortableMedia): Promise<MWMediaStream>;
|
||||
getSeasonDataFromMedia?: (media: MWPortableMedia) => Promise<MWMediaSeasons>;
|
||||
}
|
||||
|
||||
export type MWMediaProviderSeries = MWMediaProviderBase & {
|
||||
getSeasonDataFromMedia: (media: MWPortableMedia) => Promise<MWMediaSeasons>;
|
||||
};
|
||||
|
||||
export type MWMediaProvider = MWMediaProviderBase;
|
||||
|
||||
export interface MWMediaProviderMetadata {
|
||||
exists: boolean;
|
||||
id?: string;
|
||||
enabled: boolean;
|
||||
type: MWMediaType[];
|
||||
provider?: MWMediaProvider;
|
||||
}
|
||||
|
||||
export interface MWMassProviderOutput {
|
||||
providers: {
|
||||
id: string;
|
||||
success: boolean;
|
||||
}[];
|
||||
results: MWMedia[];
|
||||
stats: {
|
||||
total: number;
|
||||
failed: number;
|
||||
succeeded: number;
|
||||
};
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import contentCache from "./methods/contentCache";
|
||||
import {
|
||||
MWMedia,
|
||||
MWMediaProvider,
|
||||
MWMediaStream,
|
||||
MWPortableMedia,
|
||||
MWQuery,
|
||||
} from "./types";
|
||||
|
||||
export interface MWWrappedMediaProvider extends MWMediaProvider {
|
||||
getMediaFromPortable(media: MWPortableMedia): Promise<MWMedia>;
|
||||
searchForMedia(query: MWQuery): Promise<MWMedia[]>;
|
||||
getStream(media: MWPortableMedia): Promise<MWMediaStream>;
|
||||
}
|
||||
|
||||
export function WrapProvider(
|
||||
provider: MWMediaProvider
|
||||
): MWWrappedMediaProvider {
|
||||
return {
|
||||
...provider,
|
||||
|
||||
async getMediaFromPortable(media: MWPortableMedia): Promise<MWMedia> {
|
||||
// consult cache first
|
||||
const output = contentCache.get(media);
|
||||
if (output) {
|
||||
output.seasonId = media.seasonId;
|
||||
output.episodeId = media.episodeId;
|
||||
return output;
|
||||
}
|
||||
|
||||
const mediaObject = {
|
||||
...(await provider.getMediaFromPortable(media)),
|
||||
providerId: provider.id,
|
||||
mediaType: media.mediaType,
|
||||
};
|
||||
contentCache.set(media, mediaObject, 60 * 60);
|
||||
return mediaObject;
|
||||
},
|
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWMedia[]> {
|
||||
return (await provider.searchForMedia(query)).map<MWMedia>((m) => ({
|
||||
...m,
|
||||
providerId: provider.id,
|
||||
mediaType: query.type,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,22 +1,28 @@
|
|||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { MWMediaType } from "@/providers";
|
||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||
import { WatchedContextProvider } from "@/state/watched";
|
||||
|
||||
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
||||
import "./index.css";
|
||||
import { MediaView } from "./views/MediaView";
|
||||
import { SearchView } from "./views/SearchView";
|
||||
import { MediaView } from "@/views/media/MediaView";
|
||||
import { SearchView } from "@/views/search/SearchView";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { V2MigrationView } from "@/views/other/v2Migration";
|
||||
|
||||
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/movie/:media" component={MediaView} />
|
||||
<Route exact path="/media/series/:media" component={MediaView} />
|
||||
<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>
|
30
src/setup/chromecast.ts
Normal file
30
src/setup/chromecast.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "@/constants";
|
||||
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "./constants";
|
||||
|
||||
interface Config {
|
||||
APP_VERSION: string;
|
||||
|
@ -7,6 +7,7 @@ interface Config {
|
|||
OMDB_API_KEY: string;
|
||||
TMDB_API_KEY: string;
|
||||
CORS_PROXY_URL: string;
|
||||
NORMAL_ROUTER: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig extends Config {
|
||||
|
@ -20,6 +21,7 @@ const env: Record<keyof Config, undefined | string> = {
|
|||
GITHUB_LINK: undefined,
|
||||
DISCORD_LINK: undefined,
|
||||
CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL,
|
||||
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
|
||||
};
|
||||
|
||||
const alerts = [] as string[];
|
||||
|
@ -51,5 +53,6 @@ export function conf(): RuntimeConfig {
|
|||
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",
|
||||
};
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue