mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-14 08:05:10 +01:00
Merge remote-tracking branch 'mrjvs/master' into master-2
This commit is contained in:
commit
6b7e9b2d0a
1
.env
1
.env
@ -1 +0,0 @@
|
||||
REACT_APP_CORS_PROXY_URL=https://proxy-1.movie-web.workers.dev/?destination=
|
54
.eslintrc.js
Normal file
54
.eslintrc.js
Normal file
@ -0,0 +1,54 @@
|
||||
const a11yOff = Object.keys(require('eslint-plugin-jsx-a11y').rules)
|
||||
.reduce((acc, rule) => { acc[`jsx-a11y/${rule}`] = 'off'; return acc }, {})
|
||||
|
||||
module.exports = {
|
||||
extends: [
|
||||
"airbnb",
|
||||
"airbnb/hooks",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
],
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: "./",
|
||||
},
|
||||
plugins: ["@typescript-eslint", "import"],
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
"react/jsx-uses-react": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/require-default-props": "off",
|
||||
"react/destructuring-assignment": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"no-shadow": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
"no-restricted-syntax": "off",
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{ extensions: [".js", ".tsx", ".jsx"] },
|
||||
],
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
ts: "never",
|
||||
tsx: "never",
|
||||
},
|
||||
],
|
||||
...a11yOff
|
||||
},
|
||||
};
|
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"files.eol": "\n",
|
||||
"editor.detectIndentation": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
}
|
64
README.md
64
README.md
@ -1,26 +1,64 @@
|
||||
# movie-web
|
||||
Small web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
|
||||
<h1>movie-web</h1>
|
||||
|
||||
**[Join the Discord community](https://discord.gg/vXsRvye8BS)**
|
||||
<p align="center">
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/workflow/status/JamesHawkinss/movie-web/Build%20&%20deploy?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://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
</p>
|
||||
|
||||
## Credits
|
||||
- Thanks to [@JipFr](https://github.com/JipFr) for initial work on [movie-cli](https://github.com/JipFr/movie-cli)
|
||||
- Thanks to [@mrjvs](https://github.com/mrjvs) for help porting to React, and for the beautiful design
|
||||
- Thanks to [@JoshHeng](https://github.com/JoshHeng/) for the Cloudflare CORS Proxy and URL routing
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
|
||||
|
||||
This service works by displaying video files from third-party providers inside an intuitive and aesthic user interface.
|
||||
|
||||
Features include:
|
||||
|
||||
- 🕑 Saving of your progress so you can come back to a video at any time!
|
||||
- 🔖 Bookmarks to keep track of videos you would like to watch.
|
||||
- 🎞️ Easy switching between seasons and episodes for a TV series; binge away!
|
||||
- ✖️ Supports multiple types of content including movies, TV shows and Anime (coming soon™️)
|
||||
|
||||
## Self-hosting
|
||||
|
||||
## Installation
|
||||
To run this project locally for contributing or testing, run the following commands:
|
||||
|
||||
```
|
||||
```bash
|
||||
git clone https://github.com/JamesHawkinss/movie-web
|
||||
cd movie-web
|
||||
yarn install
|
||||
yarn start
|
||||
```
|
||||
|
||||
To build production files, simply run `yarn build`.
|
||||
|
||||
## Environment
|
||||
* `REACT_APP_CORS_PROXY_URL` - The Cloudflare CORS Proxy, will be something like `https://PROXY.workers.dev?destination=`
|
||||
<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>
|
||||
|
||||
## Contributing
|
||||
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/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
|
||||
|
||||
## Credits
|
||||
|
||||
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>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/JipFr.png?size=20" width="20"><span><a href="https://github.com/JipFr">@JipFr</a> for initial work on <a href="https://github.com/JipFr/movie-cli">movie-cli</a>.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/mrjvs.png?size=20" width="20"><span><a href="https://github.com/mrjvs">@mrjvs</a> for leading the port to React, and for the beautiful design.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/JoshHeng.png?size=20" width="20"><span><a href="https://github.com/JoshHeng">@JoshHeng</a> for the Cloudflare CORS Proxy and URL routing.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/binaryoverload.png?size=20" width="20"><span><a href="https://github.com/binaryoverload">@binaryoverload</a> for help rewriting the application into React and making the README look ✨ pretty ✨.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/lem6ns.png?size=20" width="20"><span><a href="https://github.com/lem6ns">@lem6ns</a> for helpfully implementing extra scrapers.</span>
|
||||
</div>
|
||||
|
42
package.json
42
package.json
@ -4,10 +4,13 @@
|
||||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"crypto-js": "^4.0.0",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/react-router": "^5.1.18",
|
||||
"crypto-js": "^4.1.1",
|
||||
"fuse.js": "^6.4.6",
|
||||
"hls.js": "^1.0.7",
|
||||
"json5": "^2.2.0",
|
||||
@ -15,21 +18,16 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-scripts": "^5.0.0",
|
||||
"react-tracked": "^1.7.6",
|
||||
"scheduler": "^0.20.2",
|
||||
"unpacker": "^1.0.1",
|
||||
"web-vitals": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"lint": "eslint --ext .tsx,.ts src"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@ -42,5 +40,29 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^27.4.0",
|
||||
"@types/node": "^17.0.15",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.13.0",
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^2.5.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "7.28.0",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"postcss": "^8.4.6",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
"tailwind-scrollbar": "^1.3.1",
|
||||
"tailwindcss": "^3.0.20",
|
||||
"typescript": "^4.6.4"
|
||||
}
|
||||
}
|
||||
|
@ -27,10 +27,14 @@
|
||||
<meta name="msapplication-TileColor" content="#E880C5">
|
||||
<meta name="theme-color" content="#E880C5">
|
||||
|
||||
<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" rel="stylesheet">
|
||||
|
||||
<title>movie-web</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript style="color: var(--text)">You need to enable JavaScript to run this app.</noscript>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
19
src/App.js
19
src/App.js
@ -1,19 +0,0 @@
|
||||
import { SearchView } from './views/Search';
|
||||
import { MovieView } from './views/Movie';
|
||||
import { useMovie, MovieProvider } from './hooks/useMovie';
|
||||
import './index.css';
|
||||
|
||||
function Router() {
|
||||
const { streamData } = useMovie();
|
||||
return streamData ? <MovieView /> : <SearchView />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<MovieProvider>
|
||||
<Router />
|
||||
</MovieProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
28
src/App.tsx
Normal file
28
src/App.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { MWMediaType } from "providers";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
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";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<WatchedContextProvider>
|
||||
<BookmarkContextProvider>
|
||||
<Switch>
|
||||
<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="/search/:type/:query?" component={SearchView} />
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
</BookmarkContextProvider>
|
||||
</WatchedContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
Before Width: | Height: | Size: 261 B |
@ -1,7 +0,0 @@
|
||||
.feather.left {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import React from 'react'
|
||||
import './Arrow.css'
|
||||
|
||||
// left?: boolean
|
||||
export function Arrow(props) {
|
||||
return (
|
||||
<span className="arrow" dangerouslySetInnerHTML={{ __html: `
|
||||
<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 ${props.left?'left':''}"}>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
`}}>
|
||||
</span>
|
||||
)
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
.card {
|
||||
background-color: var(--card);
|
||||
padding: 3rem 4rem;
|
||||
margin: 0 3rem;
|
||||
margin-bottom: 6rem;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
transition: height 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.card-wrapper.full {
|
||||
width: 81rem;
|
||||
}
|
||||
|
||||
.card-wrapper {
|
||||
transition: height 500ms ease-in-out;
|
||||
width: 45rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-wrapper.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.card {
|
||||
margin: 0;
|
||||
margin-bottom: 6rem;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import React from 'react'
|
||||
import './Card.css'
|
||||
|
||||
// fullWidth: boolean
|
||||
// show: boolean
|
||||
// doTransition: boolean
|
||||
export function Card(props) {
|
||||
|
||||
const [showing, setShowing] = React.useState(false);
|
||||
const measureRef = React.useRef(null)
|
||||
const [height, setHeight] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!measureRef?.current) return;
|
||||
setShowing(props.show);
|
||||
setHeight(measureRef.current.clientHeight)
|
||||
}, [props.show, measureRef])
|
||||
|
||||
return (
|
||||
<div className={`card-wrapper ${ props.fullWidth ? 'full' : '' } ${ props.doTransition ? 'overflow-hidden' : '' }`} style={{
|
||||
height: props.doTransition ? (showing ? height : 0) : "initial",
|
||||
}}>
|
||||
<div className={`card ${ showing ? 'show' : '' } ${ props.doTransition ? 'doTransition' : '' }`} ref={measureRef}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
61
src/components/Dropdown.tsx
Normal file
61
src/components/Dropdown.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
|
||||
export interface OptionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
selectedItem: OptionItem;
|
||||
setSelectedItem: (value: OptionItem) => void;
|
||||
options: Array<OptionItem>;
|
||||
}
|
||||
|
||||
export const Dropdown = React.forwardRef<HTMLDivElement, DropdownProps>(
|
||||
(props: DropdownProps) => (
|
||||
<div className="relative my-4 max-w-[18rem]">
|
||||
<Listbox value={props.selectedItem} onChange={props.setSelectedItem}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Listbox.Button className="bg-denim-500 focus-visible:ring-bink-500 focus-visible:ring-offset-bink-300 relative w-full cursor-default rounded-lg py-2 pl-3 pr-10 text-left text-white shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm">
|
||||
<span className="block truncate">{props.selectedItem.name}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<Icon
|
||||
icon={Icons.CHEVRON_DOWN}
|
||||
className={`transform transition-transform ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="bg-denim-500 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:bottom-10 sm:text-sm">
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active ? "bg-denim-400 text-bink-700" : "text-white"
|
||||
}`
|
||||
}
|
||||
key={opt.id}
|
||||
value={opt}
|
||||
>
|
||||
{opt.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
)
|
||||
);
|
@ -1,3 +0,0 @@
|
||||
.episodeSelector {
|
||||
margin-top: 20px;
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import { TypeSelector } from './TypeSelector';
|
||||
import { NumberSelector } from './NumberSelector';
|
||||
import { VideoProgressStore } from '../lib/storage/VideoProgress'
|
||||
import { SelectBox } from '../components/SelectBox';
|
||||
import './EpisodeSelector.css'
|
||||
import { useWindowSize } from '../hooks/useWindowSize';
|
||||
|
||||
export function EpisodeSelector({ setSelectedSeason, selectedSeason, setEpisode, seasons, episodes, currentSeason, currentEpisode, streamData }) {
|
||||
const choices = episodes ? episodes.map(v => {
|
||||
const progressData = VideoProgressStore.get();
|
||||
|
||||
let currentlyAt = 0;
|
||||
let totalDuration = 0;
|
||||
|
||||
const progress = progressData?.[streamData.source]?.[streamData.type]?.[streamData.slug]?.[`${selectedSeason}-${v}`]
|
||||
|
||||
if (progress) {
|
||||
currentlyAt = progress.currentlyAt
|
||||
totalDuration = progress.totalDuration
|
||||
}
|
||||
|
||||
const percentage = Math.round((currentlyAt / totalDuration) * 100)
|
||||
|
||||
return {
|
||||
value: v.toString(),
|
||||
label: v,
|
||||
percentage
|
||||
}
|
||||
}) : [];
|
||||
|
||||
const windowSize = useWindowSize()
|
||||
|
||||
return (
|
||||
<div className="episodeSelector">
|
||||
{
|
||||
(seasons.length > 0 && (windowSize.width <= 768 || seasons.length > 4)) ?
|
||||
(
|
||||
<SelectBox setSelectedItem={(index) => setSelectedSeason(seasons[index])} selectedItem={seasons.findIndex(s => s === selectedSeason)} options={seasons.map(season => { return {id: season, name: `Season ${season}` }})}/>
|
||||
)
|
||||
:
|
||||
(
|
||||
<TypeSelector setType={setSelectedSeason} selected={selectedSeason} choices={seasons.map(v=>({ value: v.toString(), label: `Season ${v}`}))} />
|
||||
)
|
||||
}
|
||||
<br></br>
|
||||
<NumberSelector setType={(e) => setEpisode({episode: e, season: selectedSeason})} choices={choices} selected={(selectedSeason.toString() === currentSeason) ? currentEpisode : null} />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
.errorBanner {
|
||||
margin-top: 0.5rem;
|
||||
border-inline-start: none;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
letter-spacing: -.01em;
|
||||
padding: .5rem 1rem .5rem .75rem;
|
||||
border-radius: .25rem;
|
||||
background-color: var(--button);
|
||||
color: var(--button-text);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import './ErrorBanner.css';
|
||||
|
||||
export function ErrorBanner({children}) {
|
||||
return (
|
||||
<div className="errorBanner">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
49
src/components/Icon.tsx
Normal file
49
src/components/Icon.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
export enum Icons {
|
||||
SEARCH = "search",
|
||||
BOOKMARK = "bookmark",
|
||||
CLOCK = "clock",
|
||||
EYE_SLASH = "eyeSlash",
|
||||
ARROW_LEFT = "arrowLeft",
|
||||
ARROW_RIGHT = "arrowRight",
|
||||
CHEVRON_DOWN = "chevronDown",
|
||||
CHEVRON_RIGHT = "chevronRight",
|
||||
CLAPPER_BOARD = "clapperBoard",
|
||||
FILM = "film",
|
||||
DRAGON = "dragon",
|
||||
WARNING = "warning",
|
||||
MOVIE_WEB = "movieWeb",
|
||||
DISCORD = "discord",
|
||||
GITHUB = "github",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
icon: Icons;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const iconList: Record<Icons, string> = {
|
||||
search: `<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="M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z"/></svg>`,
|
||||
bookmark: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 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="M384 48V512l-192-112L0 512V48C0 21.5 21.5 0 48 0h288C362.5 0 384 21.5 384 48z"/></svg>`,
|
||||
clock: `<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="M256 512C114.6 512 0 397.4 0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512zM232 256C232 264 236 271.5 242.7 275.1L338.7 339.1C349.7 347.3 364.6 344.3 371.1 333.3C379.3 322.3 376.3 307.4 365.3 300L280 243.2V120C280 106.7 269.3 96 255.1 96C242.7 96 231.1 106.7 231.1 120L232 256z"/></svg>`,
|
||||
eyeSlash: `<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="M150.7 92.77C195 58.27 251.8 32 320 32C400.8 32 465.5 68.84 512.6 112.6C559.4 156 590.7 207.1 605.5 243.7C608.8 251.6 608.8 260.4 605.5 268.3C592.1 300.6 565.2 346.1 525.6 386.7L630.8 469.1C641.2 477.3 643.1 492.4 634.9 502.8C626.7 513.2 611.6 515.1 601.2 506.9L9.196 42.89C-1.236 34.71-3.065 19.63 5.112 9.196C13.29-1.236 28.37-3.065 38.81 5.112L150.7 92.77zM223.1 149.5L313.4 220.3C317.6 211.8 320 202.2 320 191.1C320 180.5 316.1 169.7 311.6 160.4C314.4 160.1 317.2 159.1 320 159.1C373 159.1 416 202.1 416 255.1C416 269.7 413.1 282.7 407.1 294.5L446.6 324.7C457.7 304.3 464 280.9 464 255.1C464 176.5 399.5 111.1 320 111.1C282.7 111.1 248.6 126.2 223.1 149.5zM320 480C239.2 480 174.5 443.2 127.4 399.4C80.62 355.1 49.34 304 34.46 268.3C31.18 260.4 31.18 251.6 34.46 243.7C44 220.8 60.29 191.2 83.09 161.5L177.4 235.8C176.5 242.4 176 249.1 176 255.1C176 335.5 240.5 400 320 400C338.7 400 356.6 396.4 373 389.9L446.2 447.5C409.9 467.1 367.8 480 320 480H320z"/></svg>`,
|
||||
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>`,
|
||||
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>`,
|
||||
warning: `<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-alert-triangle"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
|
||||
arrowRight: `<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-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>`,
|
||||
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>`,
|
||||
};
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
return (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
|
||||
className={props.className}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
.inputBar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.inputBar > *:first-child{
|
||||
border-radius: 0 !important;
|
||||
border-top-left-radius: 10px !important;
|
||||
border-bottom-left-radius: 10px !important;
|
||||
}
|
||||
|
||||
.inputBar > *:last-child {
|
||||
border-radius: 0 !important;
|
||||
border-top-right-radius: 10px !important;
|
||||
border-bottom-right-radius: 10px !important;
|
||||
}
|
||||
|
||||
.inputTextBox {
|
||||
border-width: 0;
|
||||
outline: none;
|
||||
background-color: var(--content);
|
||||
color: var(--text);
|
||||
padding: .7rem 1.5rem;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputSearchButton {
|
||||
background-color: var(--button);
|
||||
border-width: 0;
|
||||
color: var(--button-text, var(--text));
|
||||
padding: .5rem 2.1rem;
|
||||
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputSearchButton:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
.inputTextBox:hover {
|
||||
background-color: var(--content-hover);
|
||||
}
|
||||
|
||||
.inputSearchButton .text > .arrow {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||
position: absolute;
|
||||
right: -0.8rem;
|
||||
bottom: -0.2rem;
|
||||
}
|
||||
|
||||
.inputSearchButton .text {
|
||||
display: flex;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.inputSearchButton:hover .text > .arrow {
|
||||
transform: translateX(8px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.inputSearchButton:hover .text {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
.inputSearchButton:active {
|
||||
background-color: var(--button-active);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.inputBar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.inputBar > *:nth-child(n) {
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.inputSearchButton {
|
||||
margin-top: .5rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.inputTextBox {
|
||||
margin-top: .5rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Arrow } from './Arrow';
|
||||
import './InputBox.css'
|
||||
|
||||
// props = { onSubmit: (str) => {}, placeholder: string}
|
||||
export function InputBox({ onSubmit, placeholder }) {
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
return (
|
||||
<form className="inputBar" onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(searchTerm)
|
||||
return false;
|
||||
}}>
|
||||
<input
|
||||
type='text'
|
||||
className="inputTextBox"
|
||||
id="inputTextBox"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button className="inputSearchButton">
|
||||
<span className="text">Search<span className="arrow"><Arrow /></span></span>
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
@ -1,97 +0,0 @@
|
||||
.movieRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
border-radius: 5px;
|
||||
background-color: var(--content);
|
||||
color: var(--text);
|
||||
padding: .8rem 1.5rem;
|
||||
margin-top: .5rem;
|
||||
cursor: pointer;
|
||||
transition: transform 50ms ease-in-out;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.movieRow p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.movieRow .left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
align-items: flex-start;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.movieRow .left .titleWrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.movieRow .left .seasonEpisodeSubtitle,
|
||||
.movieRow .left .year {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.movieRow .watch {
|
||||
color: var(--theme-color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.movieRow .watch .arrow {
|
||||
margin-left: .5rem;
|
||||
transition: transform 50ms ease-in-out;
|
||||
transform: translateY(.1rem);
|
||||
}
|
||||
|
||||
.movieRow:active {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.movieRow:hover {
|
||||
background-color: var(--content-hover);
|
||||
}
|
||||
|
||||
.movieRow:hover .watch .arrow {
|
||||
transform: translateX(.3rem) translateY(.1rem);
|
||||
}
|
||||
|
||||
.movieRow:focus-visible {
|
||||
border: 1px solid #fff;
|
||||
background-color: var(--content-hover);
|
||||
}
|
||||
|
||||
.movieRow:focus-visible .watch .arrow {
|
||||
transform: translateX(.3rem) translateY(.1rem);
|
||||
}
|
||||
|
||||
.attribute {
|
||||
color: var(--text);
|
||||
background-color: var(--theme-color);
|
||||
font-size: .75rem;
|
||||
padding: .25rem;
|
||||
border-radius: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.subtitleIcon {
|
||||
width: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.movieRow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.movieRow .watch {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Arrow } from './Arrow'
|
||||
import { PercentageOverlay } from './PercentageOverlay'
|
||||
import { VideoProgressStore } from '../lib/storage/VideoProgress'
|
||||
import './MovieRow.css'
|
||||
|
||||
// title: string
|
||||
// onClick: () => void
|
||||
export function MovieRow(props) {
|
||||
const progressData = VideoProgressStore.get();
|
||||
let progress;
|
||||
let percentage = null;
|
||||
|
||||
if (props.type === "movie") {
|
||||
progress = progressData?.[props.source]?.movie?.[props.slug]?.full
|
||||
|
||||
if (progress) {
|
||||
percentage = Math.floor((progress.currentlyAt / progress.totalDuration) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyPress(event){
|
||||
if ((event.code === 'Enter' || event.code === 'Space') && props.onClick){
|
||||
props.onClick();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="movieRow" tabIndex={0} onKeyPress={handleKeyPress} onClick={() => props.onClick && props.onClick()}>
|
||||
|
||||
{ (props.source === "lookmovie" || props.source === "xemovie") && (
|
||||
<div className="subtitleIcon">
|
||||
<svg id="subtitleIcon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 4H4C2.897 4 2 4.897 2 6V18C2 19.103 2.897 20 4 20H20C21.103 20 22 19.103 22 18V6C22 4.897 21.103 4 20 4ZM11 10H8V14H11V16H8C6.897 16 6 15.103 6 14V10C6 8.897 6.897 8 8 8H11V10ZM18 10H15V14H18V16H15C13.897 16 13 15.103 13 14V10C13 8.897 13.897 8 15 8H18V10Z" fill="#EEEEEE"/>
|
||||
</svg>
|
||||
</div>
|
||||
) }
|
||||
|
||||
<div className="left">
|
||||
{/* <Cross /> */}
|
||||
<div className="titleWrapper">
|
||||
<div className="titleText">
|
||||
{props.title}
|
||||
|
||||
<span className="year">({props.year})</span>
|
||||
<span className="seasonEpisodeSubtitle">{props.place ? ` - S${props.place.season}:E${props.place.episode}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="watch">
|
||||
<p>Watch {props.type}</p>
|
||||
<Arrow/>
|
||||
</div>
|
||||
|
||||
<PercentageOverlay percentage={props.percentage || percentage} />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
.numberSelector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr));
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.numberSelector .choiceWrapper {
|
||||
position: relative;
|
||||
border-radius: 10%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.numberSelector .choiceWrapper::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
|
||||
.numberSelector .choice {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--choice);
|
||||
margin-right: 5px;
|
||||
padding: .2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.numberSelector .choice:hover,
|
||||
.numberSelector .choiceWrapper:focus-visible .choice {
|
||||
background-color: var(--choice-hover);
|
||||
}
|
||||
|
||||
.numberSelector .choiceWrapper:focus-visible {
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.numberSelector .choice.selected {
|
||||
color: var(--choice-active-text, var(--text));
|
||||
background-color: var(--choice-active);
|
||||
}
|
||||
|
@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
// import { Arrow } from './Arrow';
|
||||
import './NumberSelector.css'
|
||||
import { PercentageOverlay } from './PercentageOverlay';
|
||||
|
||||
// setType: (txt: string) => void
|
||||
// choices: { label: string, value: string }[]
|
||||
// selected: string
|
||||
export function NumberSelector({ setType, choices, selected }) {
|
||||
const handleKeyPress = choice => event => {
|
||||
if (event.code === 'Space' || event.code === 'Enter'){
|
||||
setType(choice);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="numberSelector">
|
||||
{choices.map(v=>(
|
||||
<div key={v.value} className="choiceWrapper" tabIndex={0} onKeyPress={handleKeyPress(v.value)}>
|
||||
<div className={`choice ${selected&&selected===v.value?'selected':''}`} onClick={() => setType(v.value)}>
|
||||
{v.label}
|
||||
<PercentageOverlay percentage={v.percentage} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.progressBarInner {
|
||||
background: var(--theme-color);
|
||||
height: 100%;
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import React from 'react'
|
||||
import './PercentageOverlay.css'
|
||||
|
||||
export function PercentageOverlay({ percentage }) {
|
||||
|
||||
if(percentage && percentage > 3) percentage = Math.max(20, percentage < 90 ? percentage : 100)
|
||||
|
||||
return percentage > 0 ? (
|
||||
<div className="progressBar">
|
||||
<div className="progressBarInner" style={{width: `${percentage}%`}}></div>
|
||||
</div>
|
||||
) : <React.Fragment></React.Fragment>
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
.progress {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
height: 5rem;
|
||||
margin-top: 1rem;
|
||||
transition: height 800ms ease-in-out, opacity 800ms ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.progress.hide {
|
||||
opacity: 0;
|
||||
height: 0rem;
|
||||
}
|
||||
|
||||
.progress p {
|
||||
margin: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress .bar {
|
||||
width: 13rem;
|
||||
max-width: 100%;
|
||||
background-color: var(--content);
|
||||
border-radius: 10px;
|
||||
height: 7px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.progress .bar .bar-inner {
|
||||
transition: width 400ms ease-in-out, background-color 100ms ease-in-out;
|
||||
background-color: var(--theme-color);
|
||||
border-radius: 10px;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.progress.failed .bar .bar-inner {
|
||||
background-color: var(--failed);
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import React from 'react'
|
||||
import './Progress.css'
|
||||
|
||||
// show: boolean
|
||||
// progress: number
|
||||
// steps: number
|
||||
// text: string
|
||||
// failed: boolean
|
||||
export function Progress(props) {
|
||||
return (
|
||||
<div className={`progress ${props.show ? '' : 'hide'} ${props.failed ? 'failed' : ''}`}>
|
||||
{ props.text && props.text.length > 0 ? (
|
||||
<p>{props.text}</p>) : null}
|
||||
<div className="bar">
|
||||
<div className="bar-inner" style={{
|
||||
width: (props.progress / props.steps * 100).toFixed(0) + "%"
|
||||
}}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
67
src/components/SearchBar.tsx
Normal file
67
src/components/SearchBar.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import { MWMediaType, MWQuery } from "providers";
|
||||
import { DropdownButton } from "./buttons/DropdownButton";
|
||||
import { Icons } from "./Icon";
|
||||
import { TextInputControl } from "./text-inputs/TextInputControl";
|
||||
|
||||
export interface SearchBarProps {
|
||||
buttonText?: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: MWQuery) => void;
|
||||
value: MWQuery;
|
||||
}
|
||||
|
||||
export function SearchBarInput(props: SearchBarProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
function setSearch(value: string) {
|
||||
props.onChange({
|
||||
...props.value,
|
||||
searchQuery: value,
|
||||
});
|
||||
}
|
||||
function setType(type: string) {
|
||||
props.onChange({
|
||||
...props.value,
|
||||
type: type as MWMediaType,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-denim-300 hover:bg-denim-400 focus-within:bg-denim-400 flex flex-col items-center gap-4 rounded-[28px] px-4 py-4 transition-colors sm:flex-row sm:py-2 sm:pl-8 sm:pr-2">
|
||||
<TextInputControl
|
||||
onChange={(val) => setSearch(val)}
|
||||
value={props.value.searchQuery}
|
||||
className="placeholder-denim-700 w-full flex-1 bg-transparent text-white focus:outline-none"
|
||||
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: "Movie",
|
||||
icon: Icons.FILM,
|
||||
},
|
||||
{
|
||||
id: MWMediaType.SERIES,
|
||||
name: "Series",
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
// {
|
||||
// id: MWMediaType.ANIME,
|
||||
// name: "Anime",
|
||||
// icon: Icons.DRAGON,
|
||||
// },
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
{props.buttonText || "Search"}
|
||||
</DropdownButton>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,600,600i,700,700i,800,800i&display=swap');
|
||||
|
||||
/* select box styling */
|
||||
.select-box {
|
||||
display: flex;
|
||||
width: 200px;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select-box:focus-visible .selected {
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.select-box > * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.select-box .options-container {
|
||||
max-height: 0;
|
||||
width: calc( 100% - 12px);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background-color: var(--choice);
|
||||
order: 1;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
.select-box .selected {
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
width: 188px;
|
||||
height: 45px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--choice);
|
||||
color: white;
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.select-box .selected::after {
|
||||
content: "";
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
background: url(../assets/down-arrow.svg);
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 50%;
|
||||
transition: transform 150ms;
|
||||
transform: translateY(-50%);
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
|
||||
.select-box .option .item {
|
||||
color: var(--text);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.select-box .options-container.active {
|
||||
max-height: 240px;
|
||||
opacity: 1;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.select-box .options-container.active + .selected::after {
|
||||
transform: translateY(-50%) rotateX(180deg);
|
||||
}
|
||||
|
||||
.select-box .options-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: #0d141f;
|
||||
background: #81878f;
|
||||
background: #f1f2f3;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.select-box .options-container::-webkit-scrollbar-thumb {
|
||||
background: #525861;
|
||||
background: #81878f;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
.select-box .option {
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.select-box .option,
|
||||
.selected {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-box .options-container .option:hover {
|
||||
background: var(--choice-hover);
|
||||
}
|
||||
.select-box .options-container .option:hover .item {
|
||||
color: var(--choice-active-text, var(--text));
|
||||
}
|
||||
|
||||
.select-box label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-box .option .radio {
|
||||
display: none;
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
import { useRef, useState, useEffect } from "react"
|
||||
import "./SelectBox.css"
|
||||
|
||||
function Option({ option, ...props }) {
|
||||
return (
|
||||
<div className="option" {...props}>
|
||||
<input
|
||||
type="radio"
|
||||
className="radio"
|
||||
id={option.id} />
|
||||
<label htmlFor={option.id}>
|
||||
<div className="item">{option.name}</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SelectBox({ options, selectedItem, setSelectedItem }) {
|
||||
if (!Array.isArray(options)) {
|
||||
throw new Error("Items must be an array!")
|
||||
}
|
||||
|
||||
const [active, setActive] = useState(false)
|
||||
|
||||
const containerRef = useRef();
|
||||
|
||||
const handleClick = e => {
|
||||
if (containerRef.current.contains(e.target)) {
|
||||
// inside click
|
||||
return;
|
||||
}
|
||||
// outside click
|
||||
closeDropdown()
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
setActive(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// add when mounted
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
// return function to be called when unmounted
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onOptionClick = (e, option, i) => {
|
||||
e.stopPropagation()
|
||||
setSelectedItem(i)
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
const handleSelectedKeyPress = event => {
|
||||
if (event.code === 'Enter' || event.code === 'Space'){
|
||||
setActive(a => !a);
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptionKeyPress = (option, i) => event => {
|
||||
if (event.code === 'Enter' || event.code === 'Space'){
|
||||
onOptionClick(event, option, i);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="select-box" ref={containerRef} onClick={() => setActive(a => !a)} >
|
||||
<div className="selected" tabIndex={0} onKeyPress={handleSelectedKeyPress}>
|
||||
{options ? (
|
||||
<Option option={options[selectedItem]} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className={"options-container" + (active ? " active" : "")}>
|
||||
{options.map((opt, i) => (
|
||||
<Option option={opt} key={i} onClick={(e) => onOptionClick(e, opt, i)} tabIndex={active ? 0 : undefined} onKeyPress={active ? handleOptionKeyPress(opt, i) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
color: var(--text);
|
||||
/* max-width: 20rem; */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
.title-size-medium {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.title-size-small {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.title-accent {
|
||||
color: var(--theme-color);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.title-accent.title-accent-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title.accent.title-accent-link:focus-visible {
|
||||
border: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.title.accent.title-accent-link:focus-visible .arrow {
|
||||
transform: translateY(.1rem) translateX(-.5rem);
|
||||
}
|
||||
|
||||
|
||||
.title-accent.title-accent-link .arrow {
|
||||
transition: transform 100ms ease-in-out;
|
||||
transform: translateY(.1rem);
|
||||
margin-right: .2rem;
|
||||
}
|
||||
|
||||
.title-accent.title-accent-link:hover .arrow {
|
||||
transform: translateY(.1rem) translateX(-.5rem);
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useMovie } from '../hooks/useMovie'
|
||||
import { Arrow } from '../components/Arrow'
|
||||
import './Title.css'
|
||||
|
||||
// size: "big" | "medium" | "small" | null
|
||||
// accent: string | null
|
||||
// accentLink: string | null
|
||||
export function Title(props) {
|
||||
const { streamData, resetStreamData } = useMovie();
|
||||
const history = useHistory();
|
||||
const size = props.size || "big";
|
||||
|
||||
const accentLink = props.accentLink || "";
|
||||
const accent = props.accent || "";
|
||||
|
||||
function handleAccentClick(){
|
||||
if (accentLink.length > 0) {
|
||||
history.push(`/${streamData.type}`);
|
||||
resetStreamData();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyPress(event){
|
||||
if (event.code === 'Enter' || event.code === 'Space'){
|
||||
handleAccentClick();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{accent.length > 0 ? (
|
||||
<p onClick={handleAccentClick} className={`title-accent ${accentLink.length > 0 ? 'title-accent-link' : ''}`} tabIndex={accentLink.length > 0 ? 0 : undefined} onKeyPress={handleKeyPress}>
|
||||
{accentLink.length > 0 ? (<Arrow left/>) : null}{accent}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className={"title " + ( size ? `title-size-${size}` : '' )}>{props.children}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
|
||||
/* TODO better responsiveness, use dropdown if more than 5 options */
|
||||
.typeSelector {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.typeSelector:not(.nowrap) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.typeSelector::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
background-color: var(--content);
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.typeSelector .choice {
|
||||
width: 7rem;
|
||||
height: 3rem;
|
||||
padding: .3rem .2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.typeSelector .choice:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typeSelector .choice:focus-visible {
|
||||
border: 1px solid #fff;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typeSelector .choice.selected {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.typeSelector .selectedBar {
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
width: 7rem;
|
||||
background-color: var(--theme-color);
|
||||
border-radius: 2px;
|
||||
bottom: 0;
|
||||
transition: transform 150ms ease-in-out;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.typeSelector:not(.nowrap) {
|
||||
display: block;
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import './TypeSelector.css';
|
||||
|
||||
// setType: (txt: string) => void
|
||||
// choices: { label: string, value: string }[]
|
||||
// selected: string
|
||||
export function TypeSelector({ setType, choices, selected, noWrap = false }) {
|
||||
const selectedIndex = choices.findIndex(v => v.value === selected);
|
||||
const transformStyles = {
|
||||
opacity: selectedIndex !== -1 ? 1 : 0,
|
||||
transform: `translateX(${selectedIndex !== -1 ? selectedIndex * 7 : 0}rem)`,
|
||||
};
|
||||
|
||||
const handleKeyPress = choice => event => {
|
||||
if (event.code === 'Enter' || event.code === 'Space') {
|
||||
setType(choice);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`typeSelector ${noWrap ? 'nowrap' : ''}`}>
|
||||
{choices.map(v => (
|
||||
<div
|
||||
key={v.value}
|
||||
className={`choice ${selected === v.value ? 'selected' : ''}`}
|
||||
onClick={() => setType(v.value)}
|
||||
onKeyPress={handleKeyPress(v.value)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{v.label}
|
||||
</div>
|
||||
))}
|
||||
<div className="selectedBar" style={transformStyles} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
.videoElement {
|
||||
width: 100%;
|
||||
background-color: black;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.videoElementText {
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
import React from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { VideoPlaceholder } from './VideoPlaceholder'
|
||||
|
||||
import './VideoElement.css'
|
||||
|
||||
// streamUrl: string
|
||||
// loading: boolean
|
||||
// setProgress: (event: NativeEvent) => void
|
||||
// videoRef: useRef
|
||||
// startTime: number
|
||||
export function VideoElement({ streamUrl, loading, setProgress, videoRef, startTime, streamData }) {
|
||||
const [error, setError] = React.useState(false);
|
||||
|
||||
function onLoad() {
|
||||
if (startTime)
|
||||
videoRef.current.currentTime = startTime;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!streamUrl.includes('.mp4') && !streamUrl.includes('redirector.php')) {
|
||||
console.log(streamUrl)
|
||||
setError(false)
|
||||
if (!videoRef || !videoRef.current || !streamUrl || streamUrl.length === 0 || loading) return;
|
||||
|
||||
const hls = new Hls();
|
||||
|
||||
if (!Hls.isSupported() && videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoRef.current.src = streamUrl;
|
||||
return;
|
||||
} else if (!Hls.isSupported()) {
|
||||
setError(true)
|
||||
return;
|
||||
}
|
||||
|
||||
hls.attachMedia(videoRef.current);
|
||||
hls.loadSource(streamUrl);
|
||||
}
|
||||
}, [videoRef, streamUrl, loading]);
|
||||
|
||||
if (error)
|
||||
return (<VideoPlaceholder>Your browser is not supported</VideoPlaceholder>)
|
||||
|
||||
if (loading)
|
||||
return <VideoPlaceholder>Loading episode...</VideoPlaceholder>
|
||||
|
||||
if (!streamUrl || streamUrl.length === 0)
|
||||
return <VideoPlaceholder>No video selected</VideoPlaceholder>
|
||||
|
||||
if (!streamUrl.includes('.mp4') && !streamUrl.includes('redirector.php')) {
|
||||
return (
|
||||
<video className="videoElement" ref={videoRef} controls autoPlay onProgress={setProgress} onLoadedData={onLoad}>
|
||||
{ streamData.subtitles && streamData.subtitles.map((sub, index) => <track key={index} kind="captions" label={sub.language} src={sub.file} />) }
|
||||
</video>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<video className="videoElement" ref={videoRef} controls autoPlay onProgress={setProgress} onLoadedData={onLoad}>
|
||||
{ streamData.subtitles && streamData.subtitles.map((sub, index) => <track key={index} kind="captions" label={sub.language} src={sub.file} />) }
|
||||
<source src={streamUrl} type="video/mp4" />
|
||||
</video>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
.videoPlaceholder {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.videoPlaceholder::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%;
|
||||
}
|
||||
.videoPlaceholderBox {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
background: var(--choice);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import React from 'react'
|
||||
import './VideoPlaceholder.css'
|
||||
|
||||
export function VideoPlaceholder(props) {
|
||||
return (
|
||||
<div className="videoPlaceholder">
|
||||
<div className="videoPlaceholderBox">
|
||||
<p>{props.children}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
17
src/components/buttons/ButtonControl.tsx
Normal file
17
src/components/buttons/ButtonControl.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
export interface ButtonControlProps {
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ButtonControl({
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
}: ButtonControlProps) {
|
||||
return (
|
||||
<button onClick={onClick} className={className} type="button">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
129
src/components/buttons/DropdownButton.tsx
Normal file
129
src/components/buttons/DropdownButton.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
import React, {
|
||||
MouseEventHandler,
|
||||
SyntheticEvent,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { Backdrop, useBackdrop } from "components/layout/Backdrop";
|
||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface OptionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: Icons;
|
||||
}
|
||||
|
||||
interface DropdownButtonProps extends ButtonControlProps {
|
||||
icon: Icons;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
selectedItem: string;
|
||||
setSelectedItem: (value: string) => void;
|
||||
options: Array<OptionItem>;
|
||||
}
|
||||
|
||||
export interface OptionProps {
|
||||
option: OptionItem;
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
function Option({ option, onClick, tabIndex }: OptionProps) {
|
||||
return (
|
||||
<div
|
||||
className="text-denim-700 flex h-10 cursor-pointer items-center space-x-2 px-4 py-2 text-left transition-colors hover:text-white"
|
||||
onClick={onClick}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<Icon icon={option.icon} />
|
||||
<input type="radio" className="hidden" id={option.id} />
|
||||
<label htmlFor={option.id} className="cursor-pointer ">
|
||||
<div className="item">{option.name}</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const DropdownButton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DropdownButtonProps
|
||||
>((props: DropdownButtonProps, ref) => {
|
||||
const [setBackdrop, backdropProps, highlightedProps] = useBackdrop();
|
||||
const [delayedSelectedId, setDelayedSelectedId] = useState(
|
||||
props.selectedItem
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let id: NodeJS.Timeout;
|
||||
|
||||
if (props.open) {
|
||||
setDelayedSelectedId(props.selectedItem);
|
||||
} else {
|
||||
id = setTimeout(() => {
|
||||
setDelayedSelectedId(props.selectedItem);
|
||||
}, 200);
|
||||
}
|
||||
return () => {
|
||||
if (id) clearTimeout(id);
|
||||
};
|
||||
/* eslint-disable-next-line */
|
||||
}, [props.open]);
|
||||
|
||||
const selectedItem: OptionItem = props.options.find(
|
||||
(opt) => opt.id === props.selectedItem
|
||||
) || { id: "movie", name: "movie", icon: Icons.ARROW_LEFT };
|
||||
|
||||
useEffect(() => {
|
||||
setBackdrop(props.open);
|
||||
/* eslint-disable-next-line */
|
||||
}, [props.open]);
|
||||
|
||||
const onOptionClick = (e: SyntheticEvent, option: OptionItem) => {
|
||||
e.stopPropagation();
|
||||
props.setSelectedItem(option.id);
|
||||
props.setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-[140px] sm:w-auto">
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative w-full sm:w-auto"
|
||||
{...highlightedProps}
|
||||
>
|
||||
<ButtonControl
|
||||
{...props}
|
||||
className="sm:justify-left bg-bink-200 hover:bg-bink-300 relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] px-4 py-2 text-white"
|
||||
>
|
||||
<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={`bg-denim-300 absolute top-0 z-10 w-full rounded-[20px] 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>
|
||||
</div>
|
||||
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} />
|
||||
</div>
|
||||
);
|
||||
});
|
18
src/components/buttons/IconButton.tsx
Normal file
18
src/components/buttons/IconButton.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface IconButtonProps extends ButtonControlProps {
|
||||
icon: Icons;
|
||||
}
|
||||
|
||||
export function IconButton(props: IconButtonProps) {
|
||||
return (
|
||||
<ButtonControl
|
||||
{...props}
|
||||
className="flex items-center px-4 py-2 space-x-2 bg-bink-200 hover:bg-bink-300 text-white rounded-full"
|
||||
>
|
||||
<Icon icon={props.icon} />
|
||||
<span>{props.children}</span>
|
||||
</ButtonControl>
|
||||
);
|
||||
}
|
25
src/components/buttons/IconPatch.tsx
Normal file
25
src/components/buttons/IconPatch.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
|
||||
export interface IconPatchProps {
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
clickable?: boolean;
|
||||
className?: string;
|
||||
icon: Icons;
|
||||
}
|
||||
|
||||
export function IconPatch(props: IconPatchProps) {
|
||||
return (
|
||||
<div className={props.className || undefined} onClick={props.onClick}>
|
||||
<div
|
||||
className={`bg-denim-300 flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent transition-[color,transform,border-color] duration-75 ${
|
||||
props.clickable
|
||||
? "hover:bg-denim-400 m-2 cursor-pointer hover:scale-110 hover:text-white active:scale-125"
|
||||
: ""
|
||||
} ${props.active ? "text-bink-600 border-bink-600 bg-bink-100" : ""}`}
|
||||
>
|
||||
<Icon icon={props.icon} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
68
src/components/layout/Backdrop.tsx
Normal file
68
src/components/layout/Backdrop.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useFade } from "hooks/useFade";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface BackdropProps {
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
onBackdropHide?: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function useBackdrop(): [
|
||||
(state: boolean) => void,
|
||||
BackdropProps,
|
||||
{ style: any }
|
||||
] {
|
||||
const [backdrop, setBackdropState] = useState(false);
|
||||
const [isHighlighted, setisHighlighted] = useState(false);
|
||||
|
||||
const setBackdrop = (state: boolean) => {
|
||||
setBackdropState(state);
|
||||
if (state) setisHighlighted(true);
|
||||
};
|
||||
|
||||
const backdropProps: BackdropProps = {
|
||||
active: backdrop,
|
||||
onBackdropHide() {
|
||||
setisHighlighted(false);
|
||||
},
|
||||
};
|
||||
|
||||
const highlightedProps = {
|
||||
style: isHighlighted
|
||||
? {
|
||||
zIndex: "1000",
|
||||
position: "relative",
|
||||
}
|
||||
: {},
|
||||
};
|
||||
|
||||
return [setBackdrop, backdropProps, highlightedProps];
|
||||
}
|
||||
|
||||
export function Backdrop(props: BackdropProps) {
|
||||
const clickEvent = props.onClick || (() => {});
|
||||
const animationEvent = props.onBackdropHide || (() => {});
|
||||
const [isVisible, setVisible, fadeProps] = useFade();
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(!!props.active);
|
||||
/* eslint-disable-next-line */
|
||||
}, [props.active, setVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) animationEvent();
|
||||
/* eslint-disable-next-line */
|
||||
}, [isVisible]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 left-0 right-0 z-[999] h-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||
!isVisible ? "opacity-0" : ""
|
||||
}`}
|
||||
{...fadeProps}
|
||||
onClick={(e) => clickEvent(e.nativeEvent)}
|
||||
/>
|
||||
);
|
||||
}
|
16
src/components/layout/BrandPill.tsx
Normal file
16
src/components/layout/BrandPill.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
|
||||
export function BrandPill(props: { clickable?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-bink-100 text-bink-600 flex items-center space-x-2 rounded-full bg-opacity-50 px-4 py-2 ${
|
||||
props.clickable
|
||||
? "hover:bg-bink-200 hover:text-bink-700 transition-[transform,background-color] hover:scale-105 active:scale-95"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
||||
<span className="font-semibold text-white">movie-web</span>
|
||||
</div>
|
||||
);
|
||||
}
|
82
src/components/layout/ErrorBoundary.tsx
Normal file
82
src/components/layout/ErrorBoundary.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { IconPatch } from "components/buttons/IconPatch";
|
||||
import { Icons } from "components/Icon";
|
||||
import { Link } from "components/text/Link";
|
||||
import { Title } from "components/text/Title";
|
||||
import { DISCORD_LINK, GITHUB_LINK } from "mw_constants";
|
||||
import { Component } from "react";
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
Record<string, unknown>,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: { children: any }) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return {
|
||||
hasError: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: any, errorInfo: any) {
|
||||
console.error("Render error caught", error, errorInfo);
|
||||
if (error instanceof Error) {
|
||||
const realError: Error = error as Error;
|
||||
this.setState((s) => ({
|
||||
...s,
|
||||
hasError: true,
|
||||
error: {
|
||||
name: realError.name,
|
||||
description: realError.message,
|
||||
path: errorInfo.componentStack.split("\n")[1],
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
|
||||
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={DISCORD_LINK} newTab>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
or on{" "}
|
||||
<Link url={GITHUB_LINK} newTab>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{this.state.error ? (
|
||||
<div className="bg-denim-300 w-4xl mt-12 max-w-full rounded 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>
|
||||
);
|
||||
}
|
||||
}
|
22
src/components/layout/Loading.tsx
Normal file
22
src/components/layout/Loading.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
export interface LoadingProps {
|
||||
text?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Loading(props: LoadingProps) {
|
||||
return (
|
||||
<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>
|
||||
{props.text && props.text.length ? (
|
||||
<p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
43
src/components/layout/Navigation.tsx
Normal file
43
src/components/layout/Navigation.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { IconPatch } from "components/buttons/IconPatch";
|
||||
import { Icons } from "components/Icon";
|
||||
import { DISCORD_LINK, GITHUB_LINK } from "mw_constants";
|
||||
import { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-0 flex items-center justify-between py-5 px-7">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="mr-6">
|
||||
<Link to="/">
|
||||
<BrandPill clickable />
|
||||
</Link>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<a
|
||||
href={DISCORD_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
>
|
||||
<IconPatch icon={Icons.DISCORD} clickable />
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
>
|
||||
<IconPatch icon={Icons.GITHUB} clickable />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
14
src/components/layout/Paper.tsx
Normal file
14
src/components/layout/Paper.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface PaperProps {
|
||||
children?: ReactNode,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
export function Paper(props: PaperProps) {
|
||||
return (
|
||||
<div className={`bg-denim-200 rounded-xl p-12 ${props.className}`}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
119
src/components/layout/Seasons.tsx
Normal file
119
src/components/layout/Seasons.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
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 { useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
export interface SeasonsProps {
|
||||
media: MWMedia;
|
||||
}
|
||||
|
||||
export function LoadingSeasons(props: { error?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="bg-denim-400 mb-3 mt-5 h-10 w-56 rounded opacity-50" />
|
||||
</div>
|
||||
{!props.error ? (
|
||||
<>
|
||||
<div className="bg-denim-400 mr-3 mb-3 inline-block h-10 w-10 rounded opacity-50" />
|
||||
<div className="bg-denim-400 mr-3 mb-3 inline-block h-10 w-10 rounded opacity-50" />
|
||||
<div className="bg-denim-400 mr-3 mb-3 inline-block h-10 w-10 rounded opacity-50" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3">
|
||||
<IconPatch icon={Icons.WARNING} className="text-red-400" />
|
||||
<p>Failed to load seasons and episodes</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Seasons(props: SeasonsProps) {
|
||||
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 || `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}
|
||||
</>
|
||||
);
|
||||
}
|
37
src/components/layout/SectionHeading.tsx
Normal file
37
src/components/layout/SectionHeading.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
import { ArrowLink } from "components/text/ArrowLink";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
icon?: Icons;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
linkText?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeading(props: SectionHeadingProps) {
|
||||
return (
|
||||
<div className={`mt-12 ${props.className}`}>
|
||||
<div className="mb-4 flex items-end">
|
||||
<p className="text-denim-700 flex flex-1 items-center font-bold uppercase">
|
||||
{props.icon ? (
|
||||
<span className="mr-2 text-xl">
|
||||
<Icon icon={props.icon} />
|
||||
</span>
|
||||
) : null}
|
||||
{props.title}
|
||||
</p>
|
||||
{props.linkText ? (
|
||||
<ArrowLink
|
||||
linkText={props.linkText}
|
||||
direction="left"
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
16
src/components/layout/ThinContainer.tsx
Normal file
16
src/components/layout/ThinContainer.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface ThinContainerProps {
|
||||
classNames?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ThinContainer(props: ThinContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[600px] mx-auto px-2 sm:px-0 ${props.classNames || ""}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
25
src/components/media/EpisodeButton.tsx
Normal file
25
src/components/media/EpisodeButton.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
export interface EpisodeProps {
|
||||
progress?: number;
|
||||
episodeNumber: number;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
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]" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50"
|
||||
style={{
|
||||
width: `${props.progress || 0}%`,
|
||||
}}
|
||||
/>
|
||||
<span className="relative">{props.episodeNumber}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
97
src/components/media/MediaCard.tsx
Normal file
97
src/components/media/MediaCard.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import {
|
||||
convertMediaToPortable,
|
||||
getProviderFromId,
|
||||
MWMediaMeta,
|
||||
MWMediaType,
|
||||
} from "providers";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
import { serializePortableMedia } from "hooks/usePortableMedia";
|
||||
import { DotList } from "components/text/DotList";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
watchedPercentage: number;
|
||||
linkable?: boolean;
|
||||
series?: boolean;
|
||||
}
|
||||
|
||||
function MediaCardContent({
|
||||
media,
|
||||
linkable,
|
||||
watchedPercentage,
|
||||
series,
|
||||
}: MediaCardProps) {
|
||||
const provider = getProviderFromId(media.providerId);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`bg-denim-300 group relative mb-4 flex overflow-hidden rounded py-4 px-5 ${
|
||||
linkable ? "hover:bg-denim-400" : ""
|
||||
}`}
|
||||
>
|
||||
{/* progress background */}
|
||||
{watchedPercentage > 0 ? (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0">
|
||||
<div
|
||||
className="bg-bink-300 relative h-full bg-opacity-30"
|
||||
style={{
|
||||
width: `${watchedPercentage}%`,
|
||||
}}
|
||||
>
|
||||
<div className="from-bink-400 absolute right-0 top-0 bottom-0 ml-auto w-40 bg-gradient-to-l to-transparent opacity-40" />
|
||||
</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="text-denim-700 ml-2 text-xs">
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaCard(props: MediaCardProps) {
|
||||
let link = "movie";
|
||||
if (props.media.mediaType === MWMediaType.SERIES) link = "series";
|
||||
|
||||
const content = <MediaCardContent {...props} />;
|
||||
|
||||
if (!props.linkable) return <span>{content}</span>;
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${link}/${serializePortableMedia(
|
||||
convertMediaToPortable(props.media)
|
||||
)}`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
111
src/components/media/VideoPlayer.tsx
Normal file
111
src/components/media/VideoPlayer.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { IconPatch } from "components/buttons/IconPatch";
|
||||
import { Icons } from "components/Icon";
|
||||
import { Loading } from "components/layout/Loading";
|
||||
import { MWMediaCaption, MWMediaStream } from "providers";
|
||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
source: MWMediaStream;
|
||||
captions: MWMediaCaption[];
|
||||
startAt?: number;
|
||||
onProgress?: (event: ProgressEvent) => void;
|
||||
}
|
||||
|
||||
export function SkeletonVideoPlayer(props: { error?: boolean }) {
|
||||
return (
|
||||
<div className="bg-denim-200 flex aspect-video w-full items-center justify-center 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={`bg-denim-500 w-full rounded-xl ${!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>
|
||||
</>
|
||||
);
|
||||
}
|
25
src/components/media/WatchedEpisodeButton.tsx
Normal file
25
src/components/media/WatchedEpisodeButton.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
23
src/components/media/WatchedMediaCard.tsx
Normal file
23
src/components/media/WatchedMediaCard.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { MWMediaMeta } from "providers";
|
||||
import { useWatchedContext, getWatchedFromPortable } from "state/watched";
|
||||
import { MediaCard } from "./MediaCard";
|
||||
|
||||
export interface WatchedMediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
series?: boolean;
|
||||
}
|
||||
|
||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||
const { watched } = useWatchedContext();
|
||||
const foundWatched = getWatchedFromPortable(watched.items, props.media);
|
||||
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
|
||||
|
||||
return (
|
||||
<MediaCard
|
||||
watchedPercentage={watchedPercentage}
|
||||
media={props.media}
|
||||
series={props.series && props.media.episodeId !== undefined}
|
||||
linkable
|
||||
/>
|
||||
);
|
||||
}
|
39
src/components/text-inputs/TextInputControl.tsx
Normal file
39
src/components/text-inputs/TextInputControl.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
export interface TextInputControlPropsNoLabel {
|
||||
onChange?: (data: string) => void;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TextInputControlProps extends TextInputControlPropsNoLabel {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function TextInputControl({
|
||||
onChange,
|
||||
value,
|
||||
label,
|
||||
className,
|
||||
placeholder,
|
||||
}: TextInputControlProps) {
|
||||
const input = (
|
||||
<input
|
||||
type="text"
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange && onChange(e.target.value)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
{input}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
53
src/components/text/ArrowLink.tsx
Normal file
53
src/components/text/ArrowLink.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
import { Link as LinkRouter } from "react-router-dom";
|
||||
|
||||
interface IArrowLinkPropsBase {
|
||||
linkText: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
direction?: "left" | "right";
|
||||
}
|
||||
|
||||
interface IArrowLinkPropsExternal extends IArrowLinkPropsBase {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface IArrowLinkPropsInternal extends IArrowLinkPropsBase {
|
||||
to: string;
|
||||
}
|
||||
|
||||
export type ArrowLinkProps =
|
||||
| IArrowLinkPropsExternal
|
||||
| IArrowLinkPropsInternal
|
||||
| IArrowLinkPropsBase;
|
||||
|
||||
export function ArrowLink(props: ArrowLinkProps) {
|
||||
const direction = props.direction || "right";
|
||||
const isExternal = !!(props as IArrowLinkPropsExternal).url;
|
||||
const isInternal = !!(props as IArrowLinkPropsInternal).to;
|
||||
const content = (
|
||||
<span className="text-bink-600 hover:text-bink-700 group inline-flex cursor-pointer items-center space-x-1 font-bold active:scale-95">
|
||||
{direction === "left" ? (
|
||||
<span className="text-xl transition-transform group-hover:-translate-x-1">
|
||||
<Icon icon={Icons.ARROW_LEFT} />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-1">{props.linkText}</span>
|
||||
{direction === "right" ? (
|
||||
<span className="text-xl transition-transform group-hover:translate-x-1">
|
||||
<Icon icon={Icons.ARROW_RIGHT} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isExternal)
|
||||
return <a href={(props as IArrowLinkPropsExternal).url}>{content}</a>;
|
||||
if (isInternal)
|
||||
return (
|
||||
<LinkRouter to={(props as IArrowLinkPropsInternal).to}>{content}</LinkRouter>
|
||||
);
|
||||
return (
|
||||
<span onClick={() => props.onClick && props.onClick()}>{content}</span>
|
||||
);
|
||||
}
|
19
src/components/text/DotList.tsx
Normal file
19
src/components/text/DotList.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
export interface DotListProps {
|
||||
content: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DotList(props: DotListProps) {
|
||||
return (
|
||||
<p className={`text-denim-700 font-semibold ${props.className || ""}`}>
|
||||
{props.content.map((item, index) => (
|
||||
<span key={item}>
|
||||
{index !== 0 ? (
|
||||
<span className="mx-[0.6em] text-[1em]">●</span>
|
||||
) : null}
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
42
src/components/text/Link.tsx
Normal file
42
src/components/text/Link.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Link as LinkRouter } from "react-router-dom";
|
||||
|
||||
interface ILinkPropsBase {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface ILinkPropsExternal extends ILinkPropsBase {
|
||||
url: string;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface ILinkPropsInternal extends ILinkPropsBase {
|
||||
to: string;
|
||||
}
|
||||
|
||||
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">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isExternal)
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<span onClick={() => props.onClick && props.onClick()}>{content}</span>
|
||||
);
|
||||
}
|
7
src/components/text/Tagline.tsx
Normal file
7
src/components/text/Tagline.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export interface TaglineProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tagline(props: TaglineProps) {
|
||||
return <p className="font-bold text-bink-600">{props.children}</p>;
|
||||
}
|
7
src/components/text/Title.tsx
Normal file
7
src/components/text/Title.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export interface TitleProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Title(props: TitleProps) {
|
||||
return <h1 className="text-4xl font-bold text-white">{props.children}</h1>;
|
||||
}
|
20
src/hooks/useDebounce.ts
Normal file
20
src/hooks/useDebounce.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
17
src/hooks/useFade.css
Normal file
17
src/hooks/useFade.css
Normal file
@ -0,0 +1,17 @@
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
27
src/hooks/useFade.ts
Normal file
27
src/hooks/useFade.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import './useFade.css'
|
||||
|
||||
export const useFade = (initial = false): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => {
|
||||
const [show, setShow] = useState<boolean>(initial);
|
||||
const [isVisible, setVisible] = useState<boolean>(show);
|
||||
|
||||
// Update visibility when show changes
|
||||
useEffect(() => {
|
||||
if (show) setVisible(true);
|
||||
}, [show]);
|
||||
|
||||
// When the animation finishes, set visibility to false
|
||||
const onAnimationEnd = () => {
|
||||
if (!show) setVisible(false);
|
||||
};
|
||||
|
||||
const style = { animation: `${show ? "fadeIn" : "fadeOut"} .3s` };
|
||||
|
||||
// These props go on the fading DOM element
|
||||
const fadeProps = {
|
||||
style,
|
||||
onAnimationEnd
|
||||
};
|
||||
|
||||
return [isVisible, setShow, fadeProps];
|
||||
};
|
47
src/hooks/useLoading.ts
Normal file
47
src/hooks/useLoading.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
|
||||
export function useLoading<T extends (...args: any) => Promise<any>>(
|
||||
action: T
|
||||
) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<any | undefined>(undefined);
|
||||
const isMounted = useRef(true);
|
||||
|
||||
// we want action to be memoized forever
|
||||
const actionMemo = useMemo(() => action, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
React.useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const doAction = useMemo(
|
||||
() =>
|
||||
async (...args: Parameters<T>) => {
|
||||
setLoading(true);
|
||||
setSuccess(false);
|
||||
setError(undefined);
|
||||
return new Promise((resolve) => {
|
||||
actionMemo(...args)
|
||||
.then((v) => {
|
||||
if (!isMounted.current) return resolve(undefined);
|
||||
setSuccess(true);
|
||||
resolve(v);
|
||||
return null;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isMounted) {
|
||||
setError(err);
|
||||
setSuccess(false);
|
||||
}
|
||||
resolve(undefined);
|
||||
});
|
||||
}).finally(() => isMounted.current && setLoading(false));
|
||||
},
|
||||
[actionMemo]
|
||||
);
|
||||
return [doAction, loading, error, success];
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import React from 'react'
|
||||
const MovieContext = React.createContext(null)
|
||||
|
||||
export function MovieProvider(props) {
|
||||
const [page, setPage] = React.useState("search");
|
||||
const [stream, setStream] = React.useState("");
|
||||
const [streamData, setStreamData] = React.useState(null); //{ title: "", slug: "", type: "", episodes: [], seasons: [] })
|
||||
|
||||
return (
|
||||
<MovieContext.Provider value={{
|
||||
navigate(str) {
|
||||
setPage(str)
|
||||
},
|
||||
page,
|
||||
setStreamUrl: setStream,
|
||||
streamUrl: stream,
|
||||
streamData,
|
||||
setStreamData(d) {
|
||||
setStreamData(p => ({...p,...d}))
|
||||
},
|
||||
resetStreamData() { setStreamData(null) }
|
||||
}}>
|
||||
{props.children}
|
||||
</MovieContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMovie(props) {
|
||||
return React.useContext(MovieContext);
|
||||
}
|
30
src/hooks/usePortableMedia.ts
Normal file
30
src/hooks/usePortableMedia.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { MWPortableMedia } from "providers";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
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;
|
||||
}
|
34
src/hooks/useSearchQuery.ts
Normal file
34
src/hooks/useSearchQuery.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { MWMediaType, MWQuery } from "providers";
|
||||
import React, { useState } from "react";
|
||||
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
|
||||
|
||||
export function useSearchQuery(): [MWQuery, (inp: Partial<MWQuery>) => void] {
|
||||
const history = useHistory();
|
||||
const { path, params } = useRouteMatch<{ type: string; query: string }>();
|
||||
const [search, setSearch] = useState<MWQuery>({
|
||||
searchQuery: "",
|
||||
type: MWMediaType.MOVIE,
|
||||
});
|
||||
|
||||
const updateParams = (inp: Partial<MWQuery>) => {
|
||||
const copySearch: MWQuery = { ...search };
|
||||
Object.assign(copySearch, inp);
|
||||
history.replace(
|
||||
generatePath(path, {
|
||||
query:
|
||||
copySearch.searchQuery.length === 0 ? undefined : inp.searchQuery,
|
||||
type: copySearch.type,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const type =
|
||||
Object.values(MWMediaType).find((v) => params.type === v) ||
|
||||
MWMediaType.MOVIE;
|
||||
const searchQuery = params.query || "";
|
||||
setSearch({ type, searchQuery });
|
||||
}, [params, setSearch]);
|
||||
|
||||
return [search, updateParams];
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// https://usehooks.com/useWindowSize/
|
||||
export function useWindowSize() {
|
||||
// Initialize state with undefined width/height so server and client renders match
|
||||
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
useEffect(() => {
|
||||
// Handler to call on window resize
|
||||
function handleResize() {
|
||||
// Set window width/height to state
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
// Add event listener
|
||||
window.addEventListener("resize", handleResize);
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
handleResize();
|
||||
// Remove event listener on cleanup
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []); // Empty array ensures that effect is only run on mount
|
||||
return windowSize;
|
||||
}
|
@ -1,70 +1,16 @@
|
||||
:root {
|
||||
--theme-color: #E880C5;
|
||||
--theme-color-text: var(--theme-color);
|
||||
|
||||
--failed: #d85b66;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
--body: #16171D;
|
||||
--card: #22232A;
|
||||
|
||||
--text: white;
|
||||
--text-secondary: #BCBECB;
|
||||
--text-tertiary: #585A67;
|
||||
|
||||
--content: #36363e;
|
||||
--content-hover: #3C3D44;
|
||||
|
||||
--button: #A73B83;
|
||||
--button-hover: #9C3179;
|
||||
--button-active: #8b286a;
|
||||
--button-text: var(--text);
|
||||
|
||||
--choice: #2E2F37;
|
||||
--choice-hover: #45464D;
|
||||
--choice-active: #45464D;
|
||||
|
||||
--source-headings: #5b5c63;
|
||||
html,
|
||||
body {
|
||||
@apply bg-denim-100 text-denim-700 font-open-sans min-h-screen;
|
||||
}
|
||||
/* @media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--theme-color: #457461;
|
||||
|
||||
--body: white;
|
||||
--card: #f8f9fa;
|
||||
|
||||
--content: #eee;
|
||||
--content-hover: #e7e7e7;
|
||||
|
||||
--text: #333;
|
||||
--text-secondary: #616161;
|
||||
--text-tertiary: #aaa;
|
||||
|
||||
--button: #457461;
|
||||
--button-hover: #4e836e;
|
||||
--button-active: #437a64;
|
||||
--button-text: white;
|
||||
|
||||
--choice: var(--content);
|
||||
--choice-hover: var(--content-hover);
|
||||
--choice-active: var(--content-hover);
|
||||
}
|
||||
} */
|
||||
|
||||
body, html {
|
||||
margin: 0;
|
||||
background-color: var(--body);
|
||||
#root {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body, html, input, button {
|
||||
font-family: 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
14
src/index.js
14
src/index.js
@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
17
src/index.tsx
Normal file
17
src/index.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import { ErrorBoundary } from "components/layout/ErrorBoundary";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
);
|
@ -1,57 +0,0 @@
|
||||
import xemovie from './scraper/xemovie';
|
||||
import theflix from './scraper/theflix';
|
||||
import vidzstore from './scraper/vidzstore';
|
||||
import gdriveplayer from './scraper/gdriveplayer';
|
||||
import gomostream from './scraper/gomostream';
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
const results = { options: []};
|
||||
const content = await Promise.all([
|
||||
// theflix.findContent(searchTerm, type),
|
||||
gomostream.findContent(searchTerm, type),
|
||||
gdriveplayer.findContent(searchTerm, type),
|
||||
xemovie.findContent(searchTerm, type),
|
||||
// vidzstore.findContent(searchTerm, type),
|
||||
]);
|
||||
|
||||
content.forEach((o) => {
|
||||
if (!o || !o.options) return;
|
||||
|
||||
o.options.forEach((i) => {
|
||||
if (!i) return;
|
||||
results.options.push(i)
|
||||
})
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, source, season, episode) {
|
||||
switch (source) {
|
||||
case 'theflix':
|
||||
return await theflix.getStreamUrl(slug, type, season, episode);
|
||||
case 'vidzstore':
|
||||
return await vidzstore.getStreamUrl(slug);
|
||||
case 'xemovie':
|
||||
return await xemovie.getStreamUrl(slug, type, season, episode);
|
||||
case 'gdriveplayer':
|
||||
return await gdriveplayer.getStreamUrl(slug, type, season, episode);
|
||||
case 'gomostream':
|
||||
return await gomostream.getStreamUrl(slug, type, season, episode);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function getEpisodes(slug, source) {
|
||||
switch (source) {
|
||||
case 'theflix':
|
||||
return await theflix.getEpisodes(slug);
|
||||
case 'xemovie':
|
||||
return await xemovie.getEpisodes(slug);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export { findContent, getStreamUrl, getEpisodes }
|
@ -1,91 +0,0 @@
|
||||
// THIS SCRAPER DOES NOT CURRENTLY WORK AND IS NOT IN USE
|
||||
|
||||
import { unpack } from '../util/unpacker';
|
||||
|
||||
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://gomo.to`;
|
||||
const MOVIE_URL = `${BASE_URL}/movie`
|
||||
const DECODING_URL = `${BASE_URL}/decoding_v3.php`
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
if (type !== 'movie') return;
|
||||
|
||||
const term = searchTerm.toLowerCase()
|
||||
const imdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://v2.sg.media-imdb.com/suggestion/${term.slice(0, 1)}/${term}.json`).then(d => d.json())
|
||||
|
||||
const results = [];
|
||||
imdbRes.d.forEach((e) => {
|
||||
if (!e.id.startsWith('tt')) return;
|
||||
|
||||
// Block tv shows
|
||||
if (e.q === "TV series") return;
|
||||
if (e.q === "TV mini-series") return;
|
||||
if (e.q === "video game") return;
|
||||
if (e.q === "TV movie") return;
|
||||
if (e.q === "TV special") return;
|
||||
|
||||
results.push({
|
||||
title: e.l,
|
||||
slug: e.id,
|
||||
type: 'movie',
|
||||
year: e.y,
|
||||
source: 'gomostream'
|
||||
})
|
||||
});
|
||||
|
||||
if (results.length > 1) {
|
||||
return { options: results };
|
||||
} else {
|
||||
return { options: [ results[0] ] }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
if (type !== 'movie') return;
|
||||
|
||||
// Get stream to go with IMDB ID
|
||||
const site1 = await fetch(`${MOVIE_URL}/${slug}`).then((d) => d.text());
|
||||
|
||||
if (site1 === "Movie not available.")
|
||||
return { url: '' };
|
||||
|
||||
const tc = site1.match(/var tc = '(.+)';/)?.[1]
|
||||
const _token = site1.match(/"_token": "(.+)",/)?.[1]
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('tokenCode', tc)
|
||||
fd.append('_token', _token)
|
||||
|
||||
const src = await fetch(DECODING_URL, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
headers: {
|
||||
'x-token': tc.slice(5, 13).split("").reverse().join("") + "13574199"
|
||||
}
|
||||
}).then((d) => d.json());
|
||||
|
||||
const embedUrl = src.find(url => url.includes('gomo.to'));
|
||||
const site2 = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${embedUrl}`).then((d) => d.text());
|
||||
|
||||
const parser = new DOMParser();
|
||||
const site2Dom = parser.parseFromString(site2, "text/html");
|
||||
|
||||
if (site2Dom.body.innerText === "File was deleted")
|
||||
return { url: '' }
|
||||
|
||||
const script = site2Dom.querySelectorAll("script")[8].innerHTML;
|
||||
|
||||
let unpacked = unpack(script).split('');
|
||||
unpacked.splice(0, 43);
|
||||
let index = unpacked.findIndex((e) => e === '"');
|
||||
const url = unpacked.slice(0, index).join('');
|
||||
|
||||
return { url }
|
||||
}
|
||||
|
||||
const gomostream = { findContent, getStreamUrl }
|
||||
export default gomostream;
|
@ -1,164 +0,0 @@
|
||||
import Fuse from 'fuse.js'
|
||||
import JSON5 from 'json5'
|
||||
|
||||
const BASE_URL = `https://lookmovie.io`;
|
||||
const API_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://lookmovie125.xyz`;
|
||||
const CORS_URL = `${process.env.REACT_APP_CORS_PROXY_URL}${BASE_URL}`;
|
||||
let phpsessid;
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
const searchUrl = `${CORS_URL}/${type}s/search/?q=${encodeURIComponent(searchTerm)}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text());
|
||||
|
||||
// Parse DOM to find search results on full search page
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(searchRes, "text/html");
|
||||
const nodes = Array.from(doc.querySelectorAll('.movie-item-style-1'));
|
||||
const results = nodes.map(node => {
|
||||
return {
|
||||
type,
|
||||
title: node.querySelector('h6 a').innerText.trim(),
|
||||
year: node.querySelector('.year').innerText.trim(),
|
||||
slug: node.querySelector('a').href.split('/').pop(),
|
||||
}
|
||||
});
|
||||
|
||||
const fuse = new Fuse(results, { threshold: 0.3, distance: 200, keys: ["title"] });
|
||||
const matchedResults = fuse
|
||||
.search(searchTerm.toString())
|
||||
.map((result) => result.item);
|
||||
|
||||
if (matchedResults.length === 0) {
|
||||
return { options: [] }
|
||||
}
|
||||
|
||||
if (matchedResults.length > 1) {
|
||||
const res = { options: [] };
|
||||
|
||||
matchedResults.forEach((r) => res.options.push({
|
||||
title: r.title,
|
||||
slug: r.slug,
|
||||
type: r.type,
|
||||
year: r.year,
|
||||
source: 'lookmovie'
|
||||
}));
|
||||
|
||||
return res;
|
||||
} else {
|
||||
const { title, slug, type, year } = matchedResults[0];
|
||||
|
||||
return {
|
||||
options: [{ title, slug, type, year, source: 'lookmovie' }]
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return { options: [] }
|
||||
}
|
||||
}
|
||||
async function getVideoUrl(config) {
|
||||
let url = '';
|
||||
|
||||
if (config.type === 'movie') {
|
||||
url = `${API_URL}/api/v1/security/movie-access?id_movie=${config.id}&token=1&sk=&step=1`;
|
||||
} else if (config.type === 'show') {
|
||||
url = `${API_URL}/api/v1/security/episode-access?id_episode=${config.id}`;
|
||||
}
|
||||
|
||||
const data = await fetch(url, {
|
||||
headers: { phpsessid },
|
||||
}).then((d) => d.json());
|
||||
|
||||
const subs = data?.subtitles.filter((sub) => {
|
||||
if (typeof sub.file === 'object') return false;
|
||||
return true;
|
||||
})
|
||||
|
||||
// Find video URL and return it (with a check for a full url if needed)
|
||||
const opts = ["1080p", "1080", "720p", "720", "480p", "480", "auto"];
|
||||
|
||||
let videoUrl = "";
|
||||
for (let res of opts) {
|
||||
if (data.streams[res] && !data.streams[res].includes('dummy') && !data.streams[res].includes('earth-1984') && !videoUrl) {
|
||||
videoUrl = data.streams[res]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
videoUrl: videoUrl.startsWith("/") ? `${BASE_URL}${videoUrl}` : videoUrl,
|
||||
subs: subs,
|
||||
};
|
||||
}
|
||||
|
||||
async function getEpisodes(slug) {
|
||||
const url = `${CORS_URL}/shows/view/${slug}`;
|
||||
const pageReq = await fetch(url, {
|
||||
headers: { phpsessid },
|
||||
}).then((d) => d.text());
|
||||
|
||||
const data = JSON5.parse("{" +
|
||||
pageReq
|
||||
.slice(pageReq.indexOf(`show_storage`))
|
||||
.split("};")[0]
|
||||
.split("= {")[1]
|
||||
.trim() +
|
||||
"}"
|
||||
);
|
||||
|
||||
let seasons = [];
|
||||
let episodes = [];
|
||||
data.seasons.forEach((e) => {
|
||||
if (!seasons.includes(e.season))
|
||||
seasons.push(e.season);
|
||||
|
||||
if (!episodes[e.season])
|
||||
episodes[e.season] = []
|
||||
episodes[e.season].push(e.episode)
|
||||
})
|
||||
|
||||
return { seasons, episodes }
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
const url = `${CORS_URL}/${type}s/view/${slug}`;
|
||||
const pageRes = await fetch(url);
|
||||
if (pageRes.headers.get('phpsessid')) phpsessid = pageRes.headers.get('phpsessid');
|
||||
const pageResText = await pageRes.text();
|
||||
|
||||
const data = JSON5.parse("{" +
|
||||
pageResText
|
||||
.slice(pageResText.indexOf(`${type}_storage`))
|
||||
.split("};")[0]
|
||||
.split("= {")[1]
|
||||
.trim() +
|
||||
"}"
|
||||
);
|
||||
|
||||
let id = '';
|
||||
|
||||
if (type === "movie") {
|
||||
id = data.id_movie;
|
||||
} else if (type === "show") {
|
||||
const episodeObj = data.seasons.find((v) => { return v.season === season && v.episode === episode; });
|
||||
|
||||
if (episodeObj) {
|
||||
id = episodeObj.id_episode;
|
||||
}
|
||||
}
|
||||
|
||||
if (id === '') {
|
||||
return { url: '' }
|
||||
}
|
||||
|
||||
const videoUrl = await getVideoUrl({
|
||||
slug: slug,
|
||||
id: id,
|
||||
type: type,
|
||||
});
|
||||
|
||||
return { url: videoUrl.videoUrl, subtitles: videoUrl.subs };
|
||||
}
|
||||
|
||||
|
||||
const lookMovie = { findContent, getStreamUrl, getEpisodes };
|
||||
export default lookMovie;
|
@ -1,120 +0,0 @@
|
||||
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://theflix.to`;
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
const term = searchTerm.toLowerCase()
|
||||
const tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/search/${type === 'show' ? 'tv' : type}?query=${term}`).then(d => d.text());
|
||||
|
||||
const doc = new DOMParser().parseFromString(tmdbRes, 'text/html');
|
||||
const nodes = Array.from(doc.querySelectorAll('div.results > div > div.wrapper'));
|
||||
const results = nodes.slice(0, 10).map((node) => {
|
||||
let type = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('data-media-type');
|
||||
type = type === 'tv' ? 'show' : type;
|
||||
|
||||
let title;
|
||||
let year;
|
||||
let slug;
|
||||
|
||||
if (type === 'movie') {
|
||||
try {
|
||||
title = node.querySelector('div.details > div.wrapper > div.title > div > a').textContent;
|
||||
year = node.querySelector('div.details > div.wrapper > div.title > span').textContent.trim().split(' ')[2];
|
||||
slug = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('href').split('/')[2];
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line array-callback-return
|
||||
return;
|
||||
}
|
||||
} else if (type === 'show') {
|
||||
try {
|
||||
title = node.querySelector('div.details > div.wrapper > div.title > div > a > h2').textContent;
|
||||
year = node.querySelector('div.details > div.wrapper > div.title > span').textContent.trim().split(' ')[2];
|
||||
slug = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('href').split('/')[2];
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line array-callback-return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: type,
|
||||
title: title,
|
||||
year: year,
|
||||
slug: slug + '-' + title.replace(/[^a-z0-9]+|\s+/gmi, " ").replace(/\s+/g, '-').toLowerCase(),
|
||||
source: 'theflix'
|
||||
}
|
||||
});
|
||||
|
||||
if (results.length > 1) {
|
||||
return { options: results };
|
||||
} else {
|
||||
return { options: [ results[0] ] }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function getEpisodes(slug) {
|
||||
let tmdbRes;
|
||||
|
||||
try {
|
||||
tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/tv/${slug}/seasons`).then(d => d.text());
|
||||
} catch (err) {
|
||||
tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/tv/${slug.split('-')[0]}/seasons`).then(d => d.text());
|
||||
|
||||
if (tmdbRes)
|
||||
slug = slug.split('-')[0];
|
||||
}
|
||||
|
||||
const sNodes = Array.from(new DOMParser().parseFromString(tmdbRes, 'text/html').querySelectorAll('div.column_wrapper > div.flex > div'));
|
||||
|
||||
let seasons = [];
|
||||
let episodes = [];
|
||||
|
||||
for (let s of sNodes) {
|
||||
const text = s.querySelector('div > section > div > div > div > h2 > a').textContent;
|
||||
if (!text.includes('Season')) continue;
|
||||
|
||||
const season = text.split(' ')[1];
|
||||
|
||||
if (!seasons.includes(season)) {
|
||||
seasons.push(season);
|
||||
}
|
||||
|
||||
if (!episodes[season]) {
|
||||
episodes[season] = [];
|
||||
}
|
||||
|
||||
const epRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/tv/${slug}/season/${season}`).then(d => d.text());
|
||||
const epNodes = Array.from(new DOMParser().parseFromString(epRes, 'text/html').querySelectorAll('div.episode_list > div.card'));
|
||||
epNodes.forEach((e, i) => episodes[season].push(++i));
|
||||
}
|
||||
|
||||
return { seasons, episodes };
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
let url;
|
||||
|
||||
if (type === 'show') {
|
||||
url = `${BASE_URL}/tv-show/${slug}/season-${season}/episode-${episode}`;
|
||||
} else {
|
||||
url = `${BASE_URL}/movie/${slug}?movieInfo=${slug}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url).then(d => d.text());
|
||||
|
||||
const scripts = Array.from(new DOMParser().parseFromString(res, "text/html").querySelectorAll('script'));
|
||||
const prop = scripts.find((e) => e.textContent.includes("theflixvd.b-cdn"));
|
||||
|
||||
if (prop) {
|
||||
const data = JSON.parse(prop.textContent);
|
||||
return { url: data.props.pageProps.videoUrl };
|
||||
}
|
||||
|
||||
return { url: '' }
|
||||
}
|
||||
|
||||
const theflix = { findContent, getStreamUrl, getEpisodes }
|
||||
export default theflix;
|
@ -1,70 +0,0 @@
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://stream.vidzstore.com`;
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
if (type === 'show') return { options: [] };
|
||||
try {
|
||||
const searchUrl = `${BASE_URL}/search.php?sd=${searchTerm.replace(/ /g, "_")}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text());
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(searchRes, "text/html");
|
||||
const nodes = [...doc.querySelectorAll(".post")];
|
||||
const results = nodes.map(node => {
|
||||
const title = node.querySelector("a").title.replace(/-/g, " ").trim();
|
||||
const titleArray = title.split(" ");
|
||||
titleArray.splice(-2);
|
||||
return {
|
||||
type,
|
||||
title: titleArray.join(" "),
|
||||
year: node.querySelector(".post-meta").innerText.split(" ").pop().split("-").shift(),
|
||||
slug: encodeURIComponent(node.querySelector("a").href.split('/').pop()),
|
||||
source: "vidzstore",
|
||||
}
|
||||
});
|
||||
|
||||
const fuse = new Fuse(results, { threshold: 0.3, keys: ["title"] });
|
||||
const matchedResults = fuse
|
||||
.search(searchTerm)
|
||||
.map(result => result.item);
|
||||
|
||||
if (matchedResults.length === 0) {
|
||||
return { options: [] };
|
||||
}
|
||||
|
||||
if (matchedResults.length > 1) {
|
||||
const res = { options: [] };
|
||||
|
||||
matchedResults.forEach((r) => res.options.push({
|
||||
title: r.title,
|
||||
slug: r.slug,
|
||||
type: r.type,
|
||||
year: r.year,
|
||||
source: 'vidzstore'
|
||||
}));
|
||||
|
||||
return res;
|
||||
} else {
|
||||
const { title, slug, type, year } = matchedResults[0];
|
||||
|
||||
return {
|
||||
options: [{ title, slug, type, year, source: 'vidzstore' }]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { options: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug) {
|
||||
const url = `${BASE_URL}/${decodeURIComponent(slug)}`;
|
||||
|
||||
const res = await fetch(url).then(d => d.text());
|
||||
const DOM = new DOMParser().parseFromString(res, "text/html");
|
||||
|
||||
return { url: DOM.querySelector("source").src };
|
||||
}
|
||||
|
||||
const vidzstore = { findContent, getStreamUrl }
|
||||
export default vidzstore;
|
@ -1,84 +0,0 @@
|
||||
// THIS SCRAPER DOES NOT CURRENTLY WORK AND IS NOT IN USE
|
||||
|
||||
import { unpack } from '../util/unpacker';
|
||||
|
||||
const BASE_URL = `https://www.vmovee.watch`;
|
||||
const CORS_URL = `${process.env.REACT_APP_CORS_PROXY_URL}${BASE_URL}`;
|
||||
const SHOW_URL = `${CORS_URL}/series`
|
||||
const MOVIE_URL = `${CORS_URL}/movies`
|
||||
const MOVIE_URL_NO_CORS = `${BASE_URL}/movies`
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
if (type !== 'movie') return;
|
||||
|
||||
const searchUrl = `${CORS_URL}/?s=${encodeURIComponent(searchTerm)}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text());
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(searchRes, "text/html");
|
||||
const nodes = Array.from(doc.querySelectorAll('div.search-page > div.result-item > article'));
|
||||
const results = nodes.map(node => {
|
||||
const imgHolder = node.querySelector('div.image > div.thumbnail > a');
|
||||
const titleHolder = node.querySelector('div.title > a');
|
||||
|
||||
return {
|
||||
type: imgHolder.querySelector('span').textContent === 'TV' ? 'show' : 'movie',
|
||||
title: titleHolder.textContent,
|
||||
year: node.querySelector('div.details > div.meta > span.year').textContent,
|
||||
slug: titleHolder.href.split('/')[4],
|
||||
source: 'vmovee'
|
||||
}
|
||||
});
|
||||
|
||||
if (results.length > 1) {
|
||||
return { options: results };
|
||||
} else {
|
||||
return { options: [ results[0] ] }
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
let url = '';
|
||||
|
||||
if (type === 'movie') {
|
||||
url = `${MOVIE_URL}/${slug}`;
|
||||
} else if (type === 'show') {
|
||||
url = `${SHOW_URL}/${slug}`;
|
||||
}
|
||||
|
||||
const res1 = await fetch(url, { headers: new Headers().append('referer', `${BASE_URL}/dashboard/admin-ajax.php`) });
|
||||
const id = res1.headers.get('link').split('>')[0].split('?p=')[1];
|
||||
|
||||
const res2Headers = new Headers().append('referer', `${BASE_URL}/dashboard/admin-ajax.php`);
|
||||
const form = new FormData();
|
||||
form.append('action', 'doo_player_ajax')
|
||||
form.append('post', id)
|
||||
form.append('nume', '2')
|
||||
form.append('type', type)
|
||||
|
||||
const res2 = await fetch(`${CORS_URL}/dashboard/admin-ajax.php`, {
|
||||
method: 'POST',
|
||||
headers: res2Headers,
|
||||
body: form
|
||||
}).then((res) => res.json());
|
||||
let realUrl = res2.embed_url;
|
||||
|
||||
console.log(res2)
|
||||
|
||||
if (realUrl.startsWith('//')) {
|
||||
realUrl = `https:${realUrl}`;
|
||||
}
|
||||
|
||||
const res3 = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${realUrl}`);
|
||||
res3.headers.forEach(console.log)
|
||||
|
||||
return { url: '' }
|
||||
|
||||
}
|
||||
|
||||
const vmovee = { findContent, getStreamUrl }
|
||||
export default vmovee;
|
@ -1,119 +0,0 @@
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://xemovie.co`;
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
let results;
|
||||
|
||||
const searchUrl = `${BASE_URL}/search?q=${encodeURIComponent(searchTerm)}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text());
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(searchRes, "text/html");
|
||||
switch (type) {
|
||||
case 'show':
|
||||
// const showContainer = doc.querySelectorAll(".py-10")[1].querySelector(".grid");
|
||||
// const showNodes = [...showContainer.querySelectorAll("a")].filter(link => !link.className);
|
||||
// results = showNodes.map(node => {
|
||||
// node = node.parentElement
|
||||
// return {
|
||||
// type,
|
||||
// title: [...new Set(node.innerText.split("\n"))][1].split("(")[0].trim(),
|
||||
// year: [...new Set(node.innerText.split("\n"))][3],
|
||||
// slug: node.querySelector("a").href.split('/').pop(),
|
||||
// source: "xemovie"
|
||||
// }
|
||||
// })
|
||||
// break;
|
||||
return { options: [] };
|
||||
case 'movie':
|
||||
const movieContainer = doc.querySelectorAll(".py-10")[0].querySelector(".grid");
|
||||
const movieNodes = [...movieContainer.querySelectorAll("a")].filter(link => !link.className);
|
||||
results = movieNodes.map(node => {
|
||||
node = node.parentElement
|
||||
return {
|
||||
type,
|
||||
title: [...new Set(node.innerText.split("\n"))][1].split("(")[0].trim(),
|
||||
year: [...new Set(node.innerText.split("\n"))][3],
|
||||
slug: node.querySelector("a").href.split('/').pop(),
|
||||
source: "xemovie"
|
||||
}
|
||||
})
|
||||
break;
|
||||
default:
|
||||
results = [];
|
||||
break;
|
||||
}
|
||||
|
||||
const fuse = new Fuse(results, { threshold: 0.3, keys: ["title"] });
|
||||
const matchedResults = fuse
|
||||
.search(searchTerm)
|
||||
.map(result => result.item);
|
||||
|
||||
if (matchedResults.length === 0) {
|
||||
return { options: [] };
|
||||
}
|
||||
|
||||
if (matchedResults.length > 1) {
|
||||
const res = { options: [] };
|
||||
|
||||
matchedResults.forEach((r) => res.options.push({
|
||||
title: r.title,
|
||||
slug: r.slug,
|
||||
type: r.type,
|
||||
year: r.year,
|
||||
source: 'xemovie'
|
||||
}));
|
||||
|
||||
return res;
|
||||
} else {
|
||||
const { title, slug, type, year } = matchedResults[0];
|
||||
|
||||
return {
|
||||
options: [{ title, slug, type, year, source: 'xemovie' }]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { options: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
let url;
|
||||
|
||||
if (type === "show") {
|
||||
|
||||
} else {
|
||||
url = `${BASE_URL}/movies/${slug}/watch`;
|
||||
}
|
||||
|
||||
let mediaUrl = "";
|
||||
let subtitles = [];
|
||||
|
||||
const res = await fetch(url).then(d => d.text());
|
||||
const DOM = new DOMParser().parseFromString(res, "text/html");
|
||||
|
||||
for (const script of DOM.scripts) {
|
||||
if (script.textContent.match(/https:\/\/s[0-9]\.xemovie\.com/)) {
|
||||
// eslint-disable-next-line
|
||||
let data = JSON.parse(JSON.stringify(eval(`(${script.textContent.replace("const data = ", "").split("};")[0]}})`)));
|
||||
mediaUrl = data.playlist[0].file;
|
||||
for (const subtitleTrack of data.playlist[0].tracks) {
|
||||
const subtitleBlob = URL.createObjectURL(await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${subtitleTrack.file}`).then(res => res.blob())); // do this so no need for CORS errors
|
||||
subtitles.push({
|
||||
file: subtitleBlob,
|
||||
language: subtitleTrack.label
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return { url: `${process.env.REACT_APP_CORS_PROXY_URL}${mediaUrl}`, subtitles: subtitles }
|
||||
}
|
||||
|
||||
async function getEpisodes(slug) {
|
||||
|
||||
}
|
||||
|
||||
const xemovie = { findContent, getStreamUrl, getEpisodes }
|
||||
export default xemovie;
|
@ -1,43 +0,0 @@
|
||||
import { versionedStoreBuilder } from './base.js';
|
||||
|
||||
/*
|
||||
version 0
|
||||
{
|
||||
[{scraperid}]: {
|
||||
movie: {
|
||||
[{movie-id}]: {
|
||||
full: {
|
||||
currentlyAt: number,
|
||||
totalDuration: number,
|
||||
updatedAt: number, // unix timestamp in ms
|
||||
meta: FullMetaObject, // no idea whats in here
|
||||
}
|
||||
}
|
||||
},
|
||||
show: {
|
||||
[{show-id}]: {
|
||||
[{season}-{episode}]: {
|
||||
currentlyAt: number,
|
||||
totalDuration: number,
|
||||
updatedAt: number, // unix timestamp in ms
|
||||
show: {
|
||||
episode: string,
|
||||
season: string,
|
||||
},
|
||||
meta: FullMetaObject, // no idea whats in here
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
export const VideoProgressStore = versionedStoreBuilder()
|
||||
.setKey('video-progress')
|
||||
.addVersion({
|
||||
version: 0,
|
||||
create() {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
.build()
|
@ -1,230 +0,0 @@
|
||||
function buildStoreObject(d) {
|
||||
const data = {
|
||||
versions: d.versions,
|
||||
currentVersion: d.maxVersion,
|
||||
id: d.storageString,
|
||||
}
|
||||
|
||||
function update(obj) {
|
||||
if (!obj)
|
||||
throw new Error("object to update is not an object");
|
||||
|
||||
// repeat until object fully updated
|
||||
if (obj["--version"] === undefined)
|
||||
obj["--version"] = 0;
|
||||
while (obj["--version"] !== this.currentVersion) {
|
||||
// get version
|
||||
let version = obj["--version"] || 0;
|
||||
if (version.constructor !== Number || version < 0)
|
||||
version = -42; // invalid on purpose so it will reset
|
||||
else {
|
||||
version = (version+1).toString()
|
||||
}
|
||||
|
||||
// check if version exists
|
||||
if (!this.versions[version]) {
|
||||
console.error(`Version not found for storage item in store ${this.id}, resetting`);
|
||||
obj = null;
|
||||
break;
|
||||
}
|
||||
|
||||
// update object
|
||||
obj = this.versions[version].update(obj);
|
||||
}
|
||||
|
||||
// if resulting obj is null, use latest version as init object
|
||||
if (obj === null) {
|
||||
console.error(`Storage item for store ${this.id} has been reset due to faulty updates`);
|
||||
return this.versions[this.currentVersion.toString()].init();
|
||||
}
|
||||
|
||||
// updates succesful, return
|
||||
return obj;
|
||||
}
|
||||
|
||||
function get() {
|
||||
// get from storage api
|
||||
const store = this;
|
||||
let data = localStorage.getItem(this.id);
|
||||
|
||||
// parse json if item exists
|
||||
if (data) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
if (!data.constructor) {
|
||||
console.error(`Storage item for store ${this.id} has not constructor`)
|
||||
throw new Error("storage item has no constructor")
|
||||
}
|
||||
if (data.constructor !== Object) {
|
||||
console.error(`Storage item for store ${this.id} is not an object`)
|
||||
throw new Error("storage item is not an object")
|
||||
}
|
||||
} catch (_) {
|
||||
// if errored, set to null so it generates new one, see below
|
||||
console.error(`Failed to parse storage item for store ${this.id}`)
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
// if item doesnt exist, generate from version init
|
||||
if (!data) {
|
||||
data = this.versions[this.currentVersion.toString()].init();
|
||||
}
|
||||
|
||||
// update the data if needed
|
||||
data = this.update(data);
|
||||
|
||||
// add a save object to return value
|
||||
data.save = function save() {
|
||||
localStorage.setItem(store.id, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// add instance helpers
|
||||
Object.entries(d.instanceHelpers).forEach(([name, helper]) => {
|
||||
if (data[name] !== undefined)
|
||||
throw new Error(`helper name: ${name} on instance of store ${this.id} is reserved`)
|
||||
data[name] = helper.bind(data);
|
||||
})
|
||||
|
||||
// return data
|
||||
return data;
|
||||
}
|
||||
|
||||
// add functions to store
|
||||
data.get = get.bind(data);
|
||||
data.update = update.bind(data);
|
||||
|
||||
// add static helpers
|
||||
Object.entries(d.staticHelpers).forEach(([name, helper]) => {
|
||||
if (data[name] !== undefined)
|
||||
throw new Error(`helper name: ${name} on store ${data.id} is reserved`)
|
||||
data[name] = helper.bind({});
|
||||
})
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/*
|
||||
* Builds a versioned store
|
||||
*
|
||||
* manages versioning of localstorage items
|
||||
*/
|
||||
export function versionedStoreBuilder() {
|
||||
return {
|
||||
_data: {
|
||||
versionList: [],
|
||||
maxVersion: 0,
|
||||
versions: {},
|
||||
storageString: null,
|
||||
instanceHelpers: {},
|
||||
staticHelpers: {},
|
||||
},
|
||||
|
||||
/*
|
||||
* set key of localstorage item, must be unique
|
||||
*/
|
||||
setKey(str) {
|
||||
this._data.storageString = str;
|
||||
return this;
|
||||
},
|
||||
|
||||
/*
|
||||
* add a version to the store
|
||||
*
|
||||
* version: version number
|
||||
* migrate: function to update from previous version to this version
|
||||
* create: function to return an empty storage item from this version (in correct syntax)
|
||||
*/
|
||||
addVersion({ version, migrate, create }) {
|
||||
// input checking
|
||||
if (version < 0)
|
||||
throw new Error("Cannot add version below 0 in store");
|
||||
if (version > 0 && !migrate)
|
||||
throw new Error(`Missing migration on version ${version} (needed for any version above 0)`);
|
||||
|
||||
// update max version list
|
||||
if (version > this._data.maxVersion)
|
||||
this._data.maxVersion = version;
|
||||
// add to version list
|
||||
this._data.versionList.push(version);
|
||||
|
||||
|
||||
// register version
|
||||
this._data.versions[version.toString()] = {
|
||||
version: version, // version number
|
||||
update: migrate ? (data) => { // update function, and increment version
|
||||
migrate(data);
|
||||
data["--version"] = version;
|
||||
return data;
|
||||
} : null,
|
||||
init: create ? () => { // return an initial object
|
||||
const data = create();
|
||||
data["--version"] = version;
|
||||
return data;
|
||||
} : null
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
/*
|
||||
* register a instance or static helper to the store
|
||||
*
|
||||
* name: name of the helper function
|
||||
* helper: function to execute, the 'this' context is the current storage item (type is instance)
|
||||
* type: "instance" or "static". instance is put on the storage item when you store.get() it, static is on the store
|
||||
*/
|
||||
registerHelper({ name, helper, type }) {
|
||||
// type
|
||||
if (!type)
|
||||
type = "instance"
|
||||
|
||||
// input checking
|
||||
if (!name || name.constructor !== String) {
|
||||
throw new Error("helper name is not a string")
|
||||
}
|
||||
if (!helper || helper.constructor !== Function) {
|
||||
throw new Error("helper function is not a function")
|
||||
}
|
||||
if (!["instance", "static"].includes(type)) {
|
||||
throw new Error("helper type must be either 'instance' or 'static'")
|
||||
}
|
||||
|
||||
// register helper
|
||||
if (type === "instance")
|
||||
this._data.instanceHelpers[name] = helper
|
||||
else if (type === "static")
|
||||
this._data.staticHelpers[name] = helper
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
/*
|
||||
* returns function store based on what has been set
|
||||
*/
|
||||
build() {
|
||||
// check if version list doesnt skip versions
|
||||
const versionListSorted = this._data.versionList.sort((a,b)=>a-b);
|
||||
versionListSorted.forEach((v, i, arr) => {
|
||||
if (i === 0)
|
||||
return;
|
||||
if (v !== arr[i-1]+1)
|
||||
throw new Error("Version list of store is not incremental");
|
||||
})
|
||||
|
||||
// version zero must exist
|
||||
if (versionListSorted[0] !== 0)
|
||||
throw new Error("Version 0 doesn't exist in version list of store");
|
||||
|
||||
// max version must have init function
|
||||
if (!this._data.versions[this._data.maxVersion.toString()].init)
|
||||
throw new Error(`Missing create function on version ${this._data.maxVersion} (needed for latest version of store)`);
|
||||
|
||||
// check storage string
|
||||
if (!this._data.storageString)
|
||||
throw new Error("storage key not set in store");
|
||||
|
||||
// build versioned store
|
||||
return buildStoreObject(this._data);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
const alphabet = {
|
||||
62: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
95: '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'
|
||||
};
|
||||
|
||||
function _filterargs(str) {
|
||||
var juicers = [
|
||||
/}\('([\s\S]*)', *(\d+), *(\d+), *'([\s\S]*)'\.split\('\|'\), *(\d+), *([\s\S]*)\)\)/,
|
||||
/}\('([\s\S]*)', *(\d+), *(\d+), *'([\s\S]*)'\.split\('\|'\)/
|
||||
];
|
||||
|
||||
for (var c = 0; c < juicers.length; ++c) {
|
||||
var m, juicer = juicers[c];
|
||||
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
if (m = juicer.exec(str)) {
|
||||
return [m[1], m[4].split('|'), parseInt(m[2]), parseInt(m[3])];
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not make sense of p.a.c.k.e.r data (unexpected code structure)");
|
||||
}
|
||||
|
||||
function _unbaser(base) {
|
||||
if (2 <= base <= 36) return (str) => parseInt(str, base);
|
||||
|
||||
const dictionary = {};
|
||||
var alpha = alphabet[base];
|
||||
if (!alpha) throw new Error("Unsupported encoding");
|
||||
|
||||
for (let c = 0; c < alpha.length; ++alpha) {
|
||||
dictionary[alpha[c]] = c;
|
||||
}
|
||||
|
||||
return (str) => str.split("").reverse().reduce((cipher, ind) => Math.pow(base, ind) * dictionary[cipher]);
|
||||
}
|
||||
|
||||
function unpack(str) {
|
||||
var params = _filterargs(str);
|
||||
var payload = params[0], symtab = params[1], radix = params[2], count = params[3];
|
||||
|
||||
if (count !== symtab.length) {
|
||||
throw new Error("Malformed p.a.c.k.e.r. symtab. (" + count + " != " + symtab.length + ")");
|
||||
}
|
||||
|
||||
var unbase = _unbaser(radix);
|
||||
var lookup = (word) => symtab[unbase(word)] || word;
|
||||
var source = payload.replace(/\b\w+\b/g, lookup);
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
export { unpack };
|
4
src/mw_constants.ts
Normal file
4
src/mw_constants.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const CORS_PROXY_URL = "https://proxy-1.movie-web.workers.dev/?destination=";
|
||||
export const OMDB_API_KEY = "aa0937c0";
|
||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||
export const GITHUB_LINK = "https://github.com/JamesHawkinss/movie-web";
|
31
src/providers/README.md
Normal file
31
src/providers/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# 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
|
42
src/providers/index.ts
Normal file
42
src/providers/index.ts
Normal file
@ -0,0 +1,42 @@
|
||||
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);
|
||||
}
|
90
src/providers/list/gdriveplayer/index.ts
Normal file
90
src/providers/list/gdriveplayer/index.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWProviderMediaResult
|
||||
} from "providers/types";
|
||||
|
||||
import { CORS_PROXY_URL } from "mw_constants";
|
||||
import { unpack } from "unpacker";
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
const format = {
|
||||
stringify: (cipher: any) => {
|
||||
const ct = cipher.ciphertext.toString(CryptoJS.enc.Base64);
|
||||
const iv = cipher.iv.toString() || "";
|
||||
const salt = cipher.salt.toString() || "";
|
||||
return JSON.stringify({
|
||||
ct,
|
||||
iv,
|
||||
salt,
|
||||
});
|
||||
},
|
||||
parse: (jsonStr: string) => {
|
||||
const json = JSON.parse(jsonStr);
|
||||
const ciphertext = CryptoJS.enc.Base64.parse(json.ct);
|
||||
const iv = CryptoJS.enc.Hex.parse(json.iv) || "";
|
||||
const salt = CryptoJS.enc.Hex.parse(json.s) || "";
|
||||
|
||||
const cipher = CryptoJS.lib.CipherParams.create({
|
||||
ciphertext,
|
||||
iv,
|
||||
salt,
|
||||
});
|
||||
return cipher;
|
||||
}
|
||||
};
|
||||
|
||||
export const gDrivePlayerScraper: MWMediaProvider = {
|
||||
id: "gdriveplayer",
|
||||
enabled: true,
|
||||
type: [MWMediaType.MOVIE],
|
||||
displayName: "gdriveplayer",
|
||||
|
||||
async getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult> {
|
||||
const res = await fetch(`${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(`${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,
|
||||
}));
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
||||
const streamRes = await fetch(`${CORS_PROXY_URL}https://database.gdriveplayer.us/player.php?imdb=${media.mediaId}`).then((d) => d.text());
|
||||
const page = new DOMParser().parseFromString(streamRes, "text/html");
|
||||
|
||||
const script: HTMLElement | undefined = Array.from(
|
||||
page.querySelectorAll("script")
|
||||
).find((e) => e.textContent?.includes("eval"));
|
||||
|
||||
if (!script || !script.textContent) {
|
||||
throw new Error("Could not find stream");
|
||||
}
|
||||
|
||||
/// NOTE: this code requires re-write, it's not safe
|
||||
const data = unpack(script.textContent).split("var data=\\'")[1].split("\\'")[0].replace(/\\/g, "");
|
||||
const decryptedData = unpack(CryptoJS.AES.decrypt(data, "alsfheafsjklNIWORNiolNIOWNKLNXakjsfwnBdwjbwfkjbJjkopfjweopjASoiwnrflakefneiofrt", { format }).toString(CryptoJS.enc.Utf8));
|
||||
// eslint-disable-next-line
|
||||
const sources = JSON.parse(JSON.stringify(eval(decryptedData.split("sources:")[1].split(",image")[0].replace(/\\/g, "").replace(/document\.referrer/g, "\"\""))));
|
||||
const source = sources[sources.length - 1];
|
||||
/// END
|
||||
|
||||
return { url: `https:${source.file}`, type: source.type, captions: [] };
|
||||
},
|
||||
};
|
95
src/providers/list/gomostream/index.ts
Normal file
95
src/providers/list/gomostream/index.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWProviderMediaResult
|
||||
} from "providers/types";
|
||||
|
||||
import { CORS_PROXY_URL, OMDB_API_KEY } from "mw_constants";
|
||||
import { unpack } from "unpacker";
|
||||
|
||||
export const gomostreamScraper: MWMediaProvider = {
|
||||
id: "gomostream",
|
||||
enabled: true,
|
||||
type: [MWMediaType.MOVIE],
|
||||
displayName: "gomostream",
|
||||
|
||||
async getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult> {
|
||||
const params = new URLSearchParams({
|
||||
apikey: OMDB_API_KEY,
|
||||
i: media.mediaId,
|
||||
type: media.mediaType
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${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: OMDB_API_KEY,
|
||||
s: term,
|
||||
type: query.type
|
||||
});
|
||||
const searchRes = await fetch(
|
||||
`${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(`${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(`${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 embedUrl = src.find((url: string) => url.includes('gomo.to'));
|
||||
const res2 = await fetch(`${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 = res2DOM.querySelectorAll("script")[8].innerHTML;
|
||||
const unpacked = unpack(script).split('');
|
||||
unpacked.splice(0, 43);
|
||||
const index = unpacked.findIndex((e) => e === '"');
|
||||
const streamUrl = unpacked.slice(0, index).join('');
|
||||
|
||||
const streamType = streamUrl.split('.').at(-1);
|
||||
if (streamType !== "mp4" && streamType !== "m3u8") throw new Error("Unsupported stream type");
|
||||
|
||||
return { url: streamUrl, type: streamType, captions: [] };
|
||||
}
|
||||
};
|
113
src/providers/list/theflix/index.ts
Normal file
113
src/providers/list/theflix/index.ts
Normal file
@ -0,0 +1,113 @@
|
||||
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 { CORS_PROXY_URL } from "mw_constants";
|
||||
|
||||
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 = `${CORS_PROXY_URL}https://theflix.to/movie/${media.mediaId}?movieInfo=${media.mediaId}`;
|
||||
} else if (media.mediaType === MWMediaType.SERIES) {
|
||||
url = `${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 = `${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,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
36
src/providers/list/theflix/portableToMedia.ts
Normal file
36
src/providers/list/theflix/portableToMedia.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { CORS_PROXY_URL } from "mw_constants";
|
||||
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(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;
|
||||
}
|
48
src/providers/list/theflix/search.ts
Normal file
48
src/providers/list/theflix/search.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { CORS_PROXY_URL } from "mw_constants";
|
||||
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(
|
||||
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,
|
||||
};
|
||||
}
|
9
src/providers/methods/contentCache.ts
Normal file
9
src/providers/methods/contentCache.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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;
|
65
src/providers/methods/helpers.ts
Normal file
65
src/providers/methods/helpers.ts
Normal file
@ -0,0 +1,65 @@
|
||||
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,
|
||||
};
|
||||
}
|
13
src/providers/methods/providers.ts
Normal file
13
src/providers/methods/providers.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { theFlixScraper } from "providers/list/theflix";
|
||||
import { gDrivePlayerScraper } from "providers/list/gdriveplayer";
|
||||
import { MWWrappedMediaProvider, WrapProvider } from "providers/wrapper";
|
||||
import { gomostreamScraper } from "providers/list/gomostream";
|
||||
|
||||
export const mediaProvidersUnchecked: MWWrappedMediaProvider[] = [
|
||||
WrapProvider(theFlixScraper),
|
||||
WrapProvider(gDrivePlayerScraper),
|
||||
WrapProvider(gomostreamScraper),
|
||||
];
|
||||
|
||||
export const mediaProviders: MWWrappedMediaProvider[] =
|
||||
mediaProvidersUnchecked.filter((v) => v.enabled);
|
101
src/providers/methods/search.ts
Normal file
101
src/providers/methods/search.ts
Normal file
@ -0,0 +1,101 @@
|
||||
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"] });
|
||||
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;
|
||||
}
|
43
src/providers/methods/seasons.ts
Normal file
43
src/providers/methods/seasons.ts
Normal file
@ -0,0 +1,43 @@
|
||||
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;
|
||||
}
|
97
src/providers/types.ts
Normal file
97
src/providers/types.ts
Normal file
@ -0,0 +1,97 @@
|
||||
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;
|
||||
};
|
||||
}
|
48
src/providers/wrapper.ts
Normal file
48
src/providers/wrapper.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user