mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 15:33:25 +00:00
Compare commits
294 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
8200079af7 | ||
|
dcb5d2f068 | ||
|
99e47f16ea | ||
|
6fb76908ae | ||
|
a718abdcdd | ||
|
106290070a | ||
|
433d618096 | ||
|
af954af36c | ||
|
41979712c3 | ||
|
9b62b55fbb | ||
|
52598599e7 | ||
|
cccc84624a | ||
|
d54921900b | ||
|
2a4bc7349c | ||
|
7b641c61cd | ||
|
3a7b05264d | ||
|
a1e3d98538 | ||
|
3ed5dcfc15 | ||
|
71235f5174 | ||
|
0d79a677a0 | ||
|
a34d245e2b | ||
|
8b8cbc8cc9 | ||
|
5ee4f013ff | ||
|
99a3e6db69 | ||
|
7d3e1c0943 | ||
|
2cfd7e64a2 | ||
|
d6def996bf | ||
|
8bba2961b4 | ||
|
da05a2597e | ||
|
d40076e950 | ||
|
bb4a6d8a1e | ||
|
7007f030e1 | ||
|
24fa1c449f | ||
|
591b1d3bc5 | ||
|
c162f15496 | ||
|
2650707d2c | ||
|
a0a51c898a | ||
|
43c8da9003 | ||
|
1472b21600 | ||
|
2424cdfc9e | ||
|
2239c186a5 | ||
|
0c2df2cd3c | ||
|
b26b0715bd | ||
|
7b75c36d21 | ||
|
e52b29a1a1 | ||
|
12c245b2da | ||
|
871780f95e | ||
|
fa985fc2c2 | ||
|
db9eec195a | ||
|
de1221235b | ||
|
b576a298e8 | ||
|
fcb24c783c | ||
c5251401e7 | |||
41fd23cf20 | |||
|
5dfeeadbb8 | ||
|
0794558338 | ||
|
d2ffa35f2c | ||
c330112dbc | |||
84b8a67cea | |||
|
546b008b2e | ||
|
b9b0380dfe | ||
|
c472e7f7b8 | ||
|
3decc9190c | ||
|
184af19498 | ||
|
2eab07b8b6 | ||
|
5d8f03b859 | ||
|
2178057633 | ||
|
9e961223f6 | ||
|
c2b52d3db8 | ||
|
06a44da9cc | ||
|
49d7dc9761 | ||
|
1585805d86 | ||
|
7dc76e993f | ||
|
661d995e3b | ||
|
156b693460 | ||
|
d82b32e8d9 | ||
|
8a8dbb2778 | ||
|
6d95f83c0b | ||
|
2fe53a05e8 | ||
|
495222eb10 | ||
|
119bafa516 | ||
|
ba1ee0267b | ||
|
92ef687ddc | ||
|
5e776f8655 | ||
|
c541d4212a | ||
2d17c8abaa | |||
|
4a52fc11ed | ||
|
54d1af0e0a | ||
|
48f54dd7cc | ||
|
3a44eb550d | ||
|
0fa3d3e430 | ||
|
a9849b40c2 | ||
|
80954514b6 | ||
|
e2dd74c0af | ||
|
2f10de415b | ||
|
efcb12f95a | ||
|
307f555b70 | ||
|
4d5f03337d | ||
|
9f008f02d1 | ||
|
e91f65dd91 | ||
|
3aab008f12 | ||
|
659b0168c3 | ||
|
e9e2129aa2 | ||
|
bed3318ebe | ||
|
436a2388b9 | ||
|
1ad1c69d3e | ||
|
fac2b50bfc | ||
|
4d08ecc694 | ||
|
5edc99cdfe | ||
|
3b0232b3d6 | ||
|
f2ea05708f | ||
|
772777835e | ||
|
dc58c2b55e | ||
|
c7f3f774bb | ||
|
96656d9a2f | ||
|
5419430369 | ||
|
603e42b907 | ||
|
d51603a382 | ||
|
731ef6a9aa | ||
|
0de9551080 | ||
|
0f7c51c198 | ||
|
cf2060bd32 | ||
|
ec73d5ef90 | ||
|
9c159f01bd | ||
|
215b5920c3 | ||
|
6136ff92e6 | ||
|
51dfef18fb | ||
|
12f7f2ee03 | ||
|
01f46ce23c | ||
|
ffe817388a | ||
|
37d5aaede9 | ||
|
e2b1a9bfde | ||
|
827d4b576b | ||
|
5664540acc | ||
|
4fe7f1fd1c | ||
|
12555a5933 | ||
|
9fe7bdcf47 | ||
|
20addc039c | ||
|
9dad4e687d | ||
|
870aa4f105 | ||
|
464b78d914 | ||
|
06d043d482 | ||
|
01f98c583a | ||
|
f0c9103e0d | ||
|
53a0168615 | ||
|
c9ccf018f2 | ||
|
fec1d5ac15 | ||
|
9bedf2b9f1 | ||
|
57ac2ac677 | ||
|
60a5f84f2f | ||
|
0d088755ee | ||
|
e5eb09af4d | ||
|
0036c22970 | ||
|
8844efa754 | ||
|
3c68794e5b | ||
|
5fc8355e8e | ||
|
f2efd828dc | ||
|
b36324d58e | ||
|
8e79e3acdb | ||
|
31cd4d3c75 | ||
|
dfe1dd53b7 | ||
|
c2d09566b0 | ||
|
f7d51e6d8b | ||
|
c5ff5817a4 | ||
|
3aa4365a56 | ||
|
80a9f1c91b | ||
|
f02256f9e0 | ||
|
ed5435f69e | ||
|
b494469b71 | ||
|
bbb9072bc9 | ||
|
a34a644d07 | ||
|
506c00960f | ||
|
93fb343fa9 | ||
|
5e8ad2e996 | ||
|
c0867182d7 | ||
|
89f77debca | ||
|
80f7240f58 | ||
|
a520cf02bb | ||
|
051c1ba709 | ||
|
3bee46ff53 | ||
|
315c3de3ab | ||
|
1c77807987 | ||
|
9bba47575a | ||
|
dace2338be | ||
|
30d8e11992 | ||
|
9c9ce92681 | ||
|
30cc5aa78b | ||
|
ac28f32ef4 | ||
|
fca9fea265 | ||
|
c2bd7714ed | ||
|
48214af202 | ||
|
007375c1df | ||
|
72ad53ee56 | ||
|
02d94ba411 | ||
|
84913aa63d | ||
|
9d7ddc03a5 | ||
|
5327cbffaa | ||
|
695ccef2b5 | ||
|
addd8ca031 | ||
|
dd662efd72 | ||
|
900c70e36a | ||
|
68a1470447 | ||
|
b42d36c5ac | ||
|
6b9774a210 | ||
|
a5cd05b144 | ||
|
bdb4b3507a | ||
|
ca6383900a | ||
|
5e97a195d9 | ||
|
25e32a14b7 | ||
|
139a760be0 | ||
|
bd26ed5bc0 | ||
|
ef4cb064e7 | ||
|
875be16c4c | ||
|
f264457c57 | ||
|
7bf1d05f16 | ||
|
a3e244285c | ||
|
935cb2427b | ||
|
404cd897f3 | ||
|
f72d6db253 | ||
|
b9a9db348b | ||
|
fac0a878f3 | ||
|
596e680a18 | ||
|
cc51559c29 | ||
|
c6bf568514 | ||
|
4a38c77e2d | ||
|
163ca0df29 | ||
|
19d2b963a8 | ||
|
3fad6edaad | ||
|
f2f7925cbb | ||
|
b9026c50f5 | ||
|
a1f3986e64 | ||
|
224cdb6710 | ||
|
f76db3e4b7 | ||
|
9abb009725 | ||
|
0ca4b3cf49 | ||
|
9418a7c45d | ||
|
d34d2c8ce0 | ||
|
281785a0ef | ||
|
28c008a77f | ||
|
717ebbaeae | ||
|
f715f70f9e | ||
|
24aeb68f55 | ||
|
8ed0d3740f | ||
|
444c751b78 | ||
|
63b9adf7d8 | ||
|
3a1c3ad260 | ||
|
e68fe0e115 | ||
|
d51246120d | ||
|
23b439ff79 | ||
|
ac350f276c | ||
|
854e6bede4 | ||
|
25670814e4 | ||
|
7c2ad68c2a | ||
|
e82173efbe | ||
|
485698a43c | ||
|
444156236c | ||
|
4f9ef382dc | ||
|
cedc987509 | ||
|
a99437b4cc | ||
|
7f28e7be3d | ||
|
efc2c8a67d | ||
|
02cd565f84 | ||
|
0625719a4d | ||
|
16298431f4 | ||
|
7d6656aef2 | ||
|
564bcccff8 | ||
|
177df9a6f2 | ||
|
e44b36c83e | ||
|
3696a05e1e | ||
|
abeb68d4a3 | ||
|
d10d4faf56 | ||
|
f5e5b48616 | ||
|
9ff49e42a3 | ||
|
d6a46e1cdc | ||
|
d10cbd5e9b | ||
|
1853c8eac7 | ||
|
6908588c00 | ||
|
48ab781bb9 | ||
|
fbd683e0b5 | ||
|
3b3457532a | ||
|
ef7b9ff475 | ||
|
c5aacd72ce | ||
|
620e63f17c | ||
|
4d8257a05f | ||
|
0f9d7faaf2 | ||
|
afa89c02a0 | ||
|
2bef75dd4a | ||
|
35adaf3872 | ||
|
a2e5e08b20 | ||
|
39ede1b042 | ||
|
32288357c4 | ||
|
35ecaece5b | ||
|
25ccd941ca | ||
|
bfbb4c6b11 |
@@ -43,6 +43,7 @@ module.exports = {
|
||||
"no-shadow": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
"no-restricted-syntax": "off",
|
||||
"import/no-unresolved": ["error", { ignore: ["^virtual:"] }],
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"consistent-return": "off",
|
||||
"no-continue": "off",
|
||||
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
3
.github/workflows/deploying.yml
vendored
3
.github/workflows/deploying.yml
vendored
@@ -18,12 +18,13 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
run: yarn build
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v3
|
||||
|
31
.github/workflows/linting_testing.yml
vendored
31
.github/workflows/linting_testing.yml
vendored
@@ -5,8 +5,7 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
linting:
|
||||
@@ -21,20 +20,30 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Run ESLint Report
|
||||
run: yarn lint:report
|
||||
# continue on error, so it still reports it in the next step
|
||||
continue-on-error: true
|
||||
- name: Run ESLint
|
||||
run: yarn lint
|
||||
|
||||
- name: Annotate Code Linting Results
|
||||
uses: ataylorme/eslint-annotate-action@v2
|
||||
building:
|
||||
name: Build project
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
report-json: "eslint_report.json"
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Build Project
|
||||
run: npm run build
|
||||
run: yarn build
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ node_modules
|
||||
|
||||
# production
|
||||
/dist
|
||||
dev-dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"eslint.format.enable": true
|
||||
"eslint.format.enable": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
</p>
|
||||
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie-web.app](https://movie-web.app)**.
|
||||
|
||||
This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.
|
||||
|
||||
@@ -40,7 +40,7 @@ To run this project locally for contributing or testing, run the following comma
|
||||
git clone https://github.com/movie-web/movie-web
|
||||
cd movie-web
|
||||
yarn install
|
||||
yarn start
|
||||
yarn dev
|
||||
```
|
||||
|
||||
To build production files, simply run `yarn build`.
|
||||
|
29
index.html
29
index.html
@@ -1,36 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-44YVXRL61C"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "G-44YVXRL61C");
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Because watching movies legally is boring"
|
||||
content="The place for your favourite movies & shows"
|
||||
/>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#E880C5" />
|
||||
<meta name="msapplication-TileColor" content="#E880C5" />
|
||||
<meta name="theme-color" content="#E880C5" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
|
||||
<meta name="msapplication-TileColor" content="#120f1d" />
|
||||
<meta name="theme-color" content="#120f1d" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
@@ -40,7 +24,10 @@
|
||||
/>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@3744edbc5f64a77985b6421ea5040e688663634b/out.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@8b821f445b83d51ef1b8f42c99b7346f6b47dce5/out.js"></script>
|
||||
|
||||
<!-- prevent darkreader extension from messing with our already dark site -->
|
||||
<meta name="darkreader-lock" />
|
||||
|
||||
<title>movie-web</title>
|
||||
</head>
|
||||
|
37
package.json
37
package.json
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.13",
|
||||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"homepage": "https://movie-web.app",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"@react-spring/web": "^9.7.1",
|
||||
"@sentry/integrations": "^7.49.0",
|
||||
"@sentry/react": "^7.49.0",
|
||||
"@use-gesture/react": "^10.2.24",
|
||||
"core-js": "^3.29.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"dompurify": "^3.0.1",
|
||||
"fscreen": "^1.2.0",
|
||||
"fuse.js": "^6.4.6",
|
||||
"hls.js": "^1.0.7",
|
||||
@@ -20,17 +25,20 @@
|
||||
"pako": "^2.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-ga4": "^2.0.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-i18next": "^12.1.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"react-use": "^17.4.0",
|
||||
"subsrt-ts": "^2.1.0",
|
||||
"unpacker": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .tsx,.ts src",
|
||||
"lint:fix": "eslint --fix --ext .tsx,.ts src",
|
||||
@@ -38,9 +46,8 @@
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
"defaults",
|
||||
"chrome > 90"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
@@ -49,22 +56,27 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-typescript": "^7.21.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/chromecast-caf-sender": "^1.0.5",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
"@types/fscreen": "^1.0.1",
|
||||
"@types/lodash.throttle": "^4.1.7",
|
||||
"@types/node": "^17.0.15",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-router": "^5.1.18",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-stickynode": "^4.0.0",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.13.0",
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
@@ -75,6 +87,7 @@
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"jsdom": "^21.1.0",
|
||||
"postcss": "^8.4.20",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
@@ -83,6 +96,10 @@
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^4.0.1",
|
||||
"vite-plugin-checker": "^0.5.6",
|
||||
"vite-plugin-package-version": "^1.0.2"
|
||||
"vite-plugin-package-version": "^1.0.2",
|
||||
"vite-plugin-pwa": "^0.14.4",
|
||||
"vitest": "^0.28.5",
|
||||
"workbox-build": "^6.5.4",
|
||||
"workbox-window": "^6.5.4"
|
||||
}
|
||||
}
|
||||
|
5
public/_headers
Normal file
5
public/_headers
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: origin-when-cross-origin
|
@@ -3,7 +3,7 @@
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
<TileColor>#120f1d</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
@@ -1,7 +1,6 @@
|
||||
window.__CONFIG__ = {
|
||||
// url must NOT end with a slash
|
||||
VITE_CORS_PROXY_URL: "",
|
||||
|
||||
VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3",
|
||||
VITE_OMDB_API_KEY: "aa0937c0"
|
||||
VITE_OMDB_API_KEY: "aa0937c0",
|
||||
};
|
||||
|
1
public/ping.txt
Normal file
1
public/ping.txt
Normal file
@@ -0,0 +1 @@
|
||||
pong
|
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"short_name": "movie-web",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#E880C5",
|
||||
"background_color": "#16171D",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
51
src/__tests__/providers/providers.test.ts
Normal file
51
src/__tests__/providers/providers.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it } from "vitest";
|
||||
import "@/backend";
|
||||
import { getProviders } from "@/backend/helpers/register";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { runProvider } from "@/backend/helpers/run";
|
||||
import { testData } from "@/__tests__/providers/testdata";
|
||||
|
||||
describe("providers", () => {
|
||||
const providers = getProviders();
|
||||
|
||||
it("have at least one provider", ({ expect }) => {
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const provider of providers) {
|
||||
describe(provider.displayName, () => {
|
||||
it("must have at least one type", async ({ expect }) => {
|
||||
expect(provider.type.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
if (provider.type.includes(MWMediaType.MOVIE)) {
|
||||
it("must work with movies", async ({ expect }) => {
|
||||
const movie = testData.find((v) => v.meta.type === MWMediaType.MOVIE);
|
||||
if (!movie) throw new Error("no movie to test with");
|
||||
const results = await runProvider(provider, {
|
||||
media: movie,
|
||||
progress() {},
|
||||
type: movie.meta.type as any,
|
||||
});
|
||||
expect(results).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
if (provider.type.includes(MWMediaType.SERIES)) {
|
||||
it("must work with series", async ({ expect }) => {
|
||||
const show = testData.find((v) => v.meta.type === MWMediaType.SERIES);
|
||||
if (show?.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("no show to test with");
|
||||
const results = await runProvider(provider, {
|
||||
media: show,
|
||||
progress() {},
|
||||
type: show.meta.type as MWMediaType.SERIES,
|
||||
episode: show.meta.seasonData.episodes[0].id,
|
||||
season: show.meta.seasons[0].id,
|
||||
});
|
||||
expect(results).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
45
src/__tests__/providers/testdata.ts
Normal file
45
src/__tests__/providers/testdata.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
|
||||
export const testData: DetailedMeta[] = [
|
||||
{
|
||||
imdbId: "tt10954562",
|
||||
tmdbId: "572716",
|
||||
meta: {
|
||||
id: "439596",
|
||||
title: "Hamilton",
|
||||
type: MWMediaType.MOVIE,
|
||||
year: "2020",
|
||||
seasons: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
imdbId: "tt11126994",
|
||||
tmdbId: "94605",
|
||||
meta: {
|
||||
id: "222333",
|
||||
title: "Arcane",
|
||||
type: MWMediaType.SERIES,
|
||||
year: "2021",
|
||||
seasons: [
|
||||
{
|
||||
id: "230301",
|
||||
number: 1,
|
||||
title: "Season 1",
|
||||
},
|
||||
],
|
||||
seasonData: {
|
||||
id: "230301",
|
||||
number: 1,
|
||||
title: "Season 1",
|
||||
episodes: [
|
||||
{
|
||||
id: "4243445",
|
||||
number: 1,
|
||||
title: "Welcome to the Playground",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
@@ -10,6 +10,7 @@ registerEmbedScraper({
|
||||
async getStream() {
|
||||
// throw new Error("Oh well 2")
|
||||
return {
|
||||
embedId: "",
|
||||
streamUrl: "",
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
captions: [],
|
||||
|
@@ -3,7 +3,7 @@ import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
MWStream,
|
||||
MWEmbedStream,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
|
||||
@@ -13,7 +13,7 @@ const URL_API = `${URL_BASE}/api`;
|
||||
const URL_API_SOURCE = `${URL_API}/source`;
|
||||
|
||||
async function scrape(embed: string) {
|
||||
const sources: MWStream[] = [];
|
||||
const sources: MWEmbedStream[] = [];
|
||||
|
||||
const embedID = embed.split("/").pop();
|
||||
|
||||
@@ -28,6 +28,7 @@ async function scrape(embed: string) {
|
||||
|
||||
for (const stream of streams) {
|
||||
sources.push({
|
||||
embedId: "",
|
||||
streamUrl: stream.file as string,
|
||||
quality: stream.label as MWStreamQuality,
|
||||
type: stream.type as MWStreamType,
|
||||
|
@@ -1,34 +1,41 @@
|
||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
import toWebVTT from "srt-webvtt";
|
||||
import { MWCaption } from "@/backend/helpers/streams";
|
||||
import DOMPurify from "dompurify";
|
||||
import { parse, detect, list } from "subsrt-ts";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
if (caption.type === MWCaptionType.SRT) {
|
||||
let captionBlob: Blob;
|
||||
|
||||
if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
|
||||
return toWebVTT(captionBlob);
|
||||
}
|
||||
|
||||
if (caption.type === MWCaptionType.VTT) {
|
||||
if (caption.needsProxy) {
|
||||
const blob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
return caption.url;
|
||||
}
|
||||
|
||||
throw new Error("invalid type");
|
||||
export const customCaption = "external-custom";
|
||||
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||
}
|
||||
export const subtitleTypeList = list().map((type) => `.${type}`);
|
||||
export const sanitize = DOMPurify.sanitize;
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
if (caption.url.startsWith("blob:")) return caption.url;
|
||||
let captionBlob: Blob;
|
||||
if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
return URL.createObjectURL(captionBlob);
|
||||
}
|
||||
|
||||
export function revokeCaptionBlob(url: string | undefined) {
|
||||
if (url && url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubtitles(text: string): ContentCaption[] {
|
||||
if (detect(text) === "") {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
return parse(text).filter(
|
||||
(cue) => cue.type === "caption"
|
||||
) as ContentCaption[];
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { MWStream } from "./streams";
|
||||
import { MWEmbedStream } from "./streams";
|
||||
|
||||
export enum MWEmbedType {
|
||||
M4UFREE = "m4ufree",
|
||||
@@ -23,5 +23,5 @@ export type MWEmbedScraper = {
|
||||
rank: number;
|
||||
disabled?: boolean;
|
||||
|
||||
getStream(ctx: MWEmbedContext): Promise<MWStream>;
|
||||
getStream(ctx: MWEmbedContext): Promise<MWEmbedStream>;
|
||||
};
|
||||
|
@@ -1,6 +1,15 @@
|
||||
import { conf } from "@/setup/config";
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
|
||||
|
||||
// round robins all proxy urls
|
||||
function getProxyUrl(): string {
|
||||
const url = conf().PROXY_URLS[proxyUrlIndex];
|
||||
proxyUrlIndex = (proxyUrlIndex + 1) % conf().PROXY_URLS.length;
|
||||
return url;
|
||||
}
|
||||
|
||||
type P<T> = Parameters<typeof ofetch<T>>;
|
||||
type R<T> = ReturnType<typeof ofetch<T>>;
|
||||
|
||||
@@ -41,7 +50,7 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return baseFetch<T>(conf().BASE_PROXY_URL, {
|
||||
return baseFetch<T>(getProxyUrl(), {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
params: {
|
||||
|
@@ -43,7 +43,13 @@ async function findBestEmbedStream(
|
||||
providerId: string,
|
||||
ctx: MWProviderRunContext
|
||||
): Promise<MWStream | null> {
|
||||
if (result.stream) return result.stream;
|
||||
if (result.stream) {
|
||||
return {
|
||||
...result.stream,
|
||||
providerId,
|
||||
embedId: providerId,
|
||||
};
|
||||
}
|
||||
|
||||
let embedNum = 0;
|
||||
for (const embed of result.embeds) {
|
||||
@@ -89,6 +95,7 @@ async function findBestEmbedStream(
|
||||
type: "embed",
|
||||
});
|
||||
|
||||
stream.providerId = providerId;
|
||||
return stream;
|
||||
}
|
||||
|
||||
|
@@ -6,10 +6,12 @@ export enum MWStreamType {
|
||||
export enum MWCaptionType {
|
||||
VTT = "vtt",
|
||||
SRT = "srt",
|
||||
UNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
export enum MWStreamQuality {
|
||||
Q360P = "360p",
|
||||
Q540P = "540p",
|
||||
Q480P = "480p",
|
||||
Q720P = "720p",
|
||||
Q1080P = "1080p",
|
||||
@@ -27,5 +29,11 @@ export type MWStream = {
|
||||
streamUrl: string;
|
||||
type: MWStreamType;
|
||||
quality: MWStreamQuality;
|
||||
providerId?: string;
|
||||
embedId?: string;
|
||||
captions: MWCaption[];
|
||||
};
|
||||
|
||||
export type MWEmbedStream = MWStream & {
|
||||
embedId: string;
|
||||
};
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import { initializeScraperStore } from "./helpers/register";
|
||||
|
||||
// providers
|
||||
import "./providers/gdriveplayer";
|
||||
// import "./providers/gdriveplayer";
|
||||
import "./providers/flixhq";
|
||||
import "./providers/superstream";
|
||||
import "./providers/netfilm";
|
||||
import "./providers/m4ufree";
|
||||
import "./providers/hdwatched";
|
||||
|
||||
// embeds
|
||||
import "./embeds/streamm4u";
|
||||
|
@@ -54,12 +54,17 @@ export async function getMetaFromId(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const imdbId = data.external_ids.find(
|
||||
let imdbId = data.external_ids.find(
|
||||
(v) => v.provider === "imdb_latest"
|
||||
)?.external_id;
|
||||
const tmdbId = data.external_ids.find(
|
||||
if (!imdbId)
|
||||
imdbId = data.external_ids.find((v) => v.provider === "imdb")?.external_id;
|
||||
|
||||
let tmdbId = data.external_ids.find(
|
||||
(v) => v.provider === "tmdb_latest"
|
||||
)?.external_id;
|
||||
if (!tmdbId)
|
||||
tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id;
|
||||
|
||||
if (!imdbId || !tmdbId) throw new Error("not enough info");
|
||||
|
||||
|
@@ -21,7 +21,7 @@ export type JWMediaResult = {
|
||||
title: string;
|
||||
poster?: string;
|
||||
id: number;
|
||||
original_release_year: number;
|
||||
original_release_year?: number;
|
||||
jw_entity_id: string;
|
||||
object_type: JWContentTypes;
|
||||
seasons?: JWSeasonShort[];
|
||||
@@ -67,7 +67,7 @@ export function formatJWMeta(
|
||||
return {
|
||||
title: media.title,
|
||||
id: media.id.toString(),
|
||||
year: media.original_release_year.toString(),
|
||||
year: media.original_release_year?.toString(),
|
||||
poster: media.poster
|
||||
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
|
||||
: undefined,
|
||||
|
@@ -24,7 +24,7 @@ export type MWSeasonWithEpisodeMeta = {
|
||||
type MWMediaMetaBase = {
|
||||
title: string;
|
||||
id: string;
|
||||
year: string;
|
||||
year?: string;
|
||||
poster?: string;
|
||||
};
|
||||
|
||||
|
@@ -1,18 +1,58 @@
|
||||
import { compareTitle } from "@/utils/titleMatch";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const flixHqBase = "https://api.consumet.org/movies/flixhq";
|
||||
const flixHqBase = "https://api.consumet.org/meta/tmdb";
|
||||
|
||||
type FlixHQMediaType = "Movie" | "TV Series";
|
||||
interface FLIXMediaBase {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
image: string;
|
||||
type: FlixHQMediaType;
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
function castSubtitles({ url, lang }: { url: string; lang: string }) {
|
||||
return {
|
||||
url,
|
||||
langIso: lang,
|
||||
type:
|
||||
url.substring(url.length - 3) === "vtt"
|
||||
? MWCaptionType.VTT
|
||||
: MWCaptionType.SRT,
|
||||
};
|
||||
}
|
||||
|
||||
const qualityMap: Record<string, MWStreamQuality> = {
|
||||
"360": MWStreamQuality.Q360P,
|
||||
"540": MWStreamQuality.Q540P,
|
||||
"480": MWStreamQuality.Q480P,
|
||||
"720": MWStreamQuality.Q720P,
|
||||
"1080": MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
function flixTypeToMWType(type: FlixHQMediaType) {
|
||||
if (type === "Movie") return MWMediaType.MOVIE;
|
||||
return MWMediaType.SERIES;
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "flixhq",
|
||||
displayName: "FlixHQ",
|
||||
rank: 100,
|
||||
type: [MWMediaType.MOVIE],
|
||||
|
||||
async scrape({ media, progress }) {
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
// search for relevant item
|
||||
const searchResults = await proxiedFetch<any>(
|
||||
`/${encodeURIComponent(media.meta.title)}`,
|
||||
@@ -20,46 +60,67 @@ registerProvider({
|
||||
baseURL: flixHqBase,
|
||||
}
|
||||
);
|
||||
const foundItem = searchResults.results.find((v: any) => {
|
||||
|
||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
||||
if (v.type !== "Movie" && v.type !== "TV Series") return false;
|
||||
return (
|
||||
compareTitle(v.title, media.meta.title) &&
|
||||
flixTypeToMWType(v.type) === media.meta.type &&
|
||||
v.releaseDate === media.meta.year
|
||||
);
|
||||
});
|
||||
|
||||
if (!foundItem) throw new Error("No watchable item found");
|
||||
const flixId = foundItem.id;
|
||||
|
||||
// get media info
|
||||
progress(25);
|
||||
const mediaInfo = await proxiedFetch<any>("/info", {
|
||||
const mediaInfo = await proxiedFetch<any>(`/info/${foundItem.id}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
id: flixId,
|
||||
type: flixTypeToMWType(foundItem.type),
|
||||
},
|
||||
});
|
||||
if (!mediaInfo.id) throw new Error("No watchable item found");
|
||||
// get stream info from media
|
||||
progress(50);
|
||||
|
||||
let episodeId: string | undefined;
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
episodeId = mediaInfo.episodeId;
|
||||
} else if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasonNo = media.meta.seasonData.number;
|
||||
const episodeNo = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
|
||||
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
|
||||
}
|
||||
if (!episodeId) throw new Error("No watchable item found");
|
||||
progress(75);
|
||||
const watchInfo = await proxiedFetch<any>(`/watch/${episodeId}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
id: mediaInfo.id,
|
||||
},
|
||||
});
|
||||
|
||||
// get stream info from media
|
||||
progress(75);
|
||||
const watchInfo = await proxiedFetch<any>("/watch", {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
episodeId: mediaInfo.episodes[0].id,
|
||||
mediaId: flixId,
|
||||
},
|
||||
});
|
||||
if (!watchInfo.sources) throw new Error("No watchable item found");
|
||||
|
||||
// get best quality source
|
||||
const source = watchInfo.sources.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
|
||||
// comes sorted by quality in descending order
|
||||
const source = watchInfo.sources[0];
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
quality: MWStreamQuality.QUNKNOWN,
|
||||
quality: qualityMap[source.quality],
|
||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
||||
captions: [],
|
||||
captions: watchInfo.subtitles
|
||||
.filter(
|
||||
(x: { url: string; lang: string }) => !x.lang.includes("(maybe)")
|
||||
)
|
||||
.map(castSubtitles),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@@ -35,6 +35,7 @@ const format = {
|
||||
registerProvider({
|
||||
id: "gdriveplayer",
|
||||
displayName: "gdriveplayer",
|
||||
disabled: true,
|
||||
rank: 69,
|
||||
type: [MWMediaType.MOVIE],
|
||||
|
||||
|
196
src/backend/providers/hdwatched.ts
Normal file
196
src/backend/providers/hdwatched.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { MWProviderContext } from "../helpers/provider";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const hdwatchedBase = "https://www.hdwatched.xyz";
|
||||
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
interface SearchRes {
|
||||
title: string;
|
||||
year?: number;
|
||||
href: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function getStreamFromEmbed(stream: string) {
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch stream");
|
||||
}
|
||||
|
||||
const streamSrc = source.getAttribute("src");
|
||||
const streamRes = source.getAttribute("res");
|
||||
|
||||
if (!streamSrc || !streamRes) throw new Error("Unable to find stream");
|
||||
|
||||
return {
|
||||
streamUrl: streamSrc,
|
||||
quality:
|
||||
streamRes && typeof +streamRes === "number"
|
||||
? qualityMap[+streamRes]
|
||||
: MWStreamQuality.QUNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMovie(targetSource: SearchRes) {
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetSource.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
async function fetchSeries(
|
||||
targetSource: SearchRes,
|
||||
{ media, episode, progress }: MWProviderContext
|
||||
) {
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Media type mismatch");
|
||||
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
if (!seasonNumber || !episodeNumber)
|
||||
throw new Error("Unable to get season or episode number");
|
||||
|
||||
const seriesPage = await proxiedFetch<any>(
|
||||
`${targetSource.href}?season=${media.meta.seasonData.number}`,
|
||||
{
|
||||
baseURL: hdwatchedBase,
|
||||
}
|
||||
);
|
||||
|
||||
const seasonPage = new DOMParser().parseFromString(seriesPage, "text/html");
|
||||
const pageElements = seasonPage.querySelectorAll("div.i-container");
|
||||
|
||||
const seriesList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
|
||||
seriesList.push({
|
||||
title,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{series-slug}-season-{season-number}-episode-{episode-number}
|
||||
});
|
||||
});
|
||||
|
||||
const targetEpisode = seriesList.find(
|
||||
(episodeEl) =>
|
||||
episodeEl.title.trim().toLowerCase() === `episode ${episodeNumber}`
|
||||
);
|
||||
|
||||
if (!targetEpisode) throw new Error("Unable to find episode");
|
||||
|
||||
progress(70);
|
||||
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetEpisode.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "hdwatched",
|
||||
displayName: "HDwatched",
|
||||
rank: 150,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape(options) {
|
||||
const { media, progress } = options;
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
|
||||
const search = await proxiedFetch<any>(`/search/${media.imdbId}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||
const pageElements = searchPage.querySelectorAll("div.i-container");
|
||||
|
||||
const searchList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
const year =
|
||||
parseInt(
|
||||
pageElement
|
||||
?.querySelector("div.duration")
|
||||
?.textContent?.trim()
|
||||
?.split(" ")
|
||||
?.pop() || "",
|
||||
10
|
||||
) || 0;
|
||||
|
||||
searchList.push({
|
||||
title,
|
||||
year,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{movie-slug} or /series/{id}/{series-slug}
|
||||
});
|
||||
});
|
||||
|
||||
progress(20);
|
||||
|
||||
const targetSource = searchList.find(
|
||||
(source) => source.year === (media.meta.year ? +media.meta.year : 0) // Compare year to make the search more robust
|
||||
);
|
||||
|
||||
if (!targetSource) {
|
||||
throw new Error("Could not find stream");
|
||||
}
|
||||
|
||||
progress(40);
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const series = await fetchSeries(targetSource, options);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: series.streamUrl,
|
||||
quality: series.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const movie = await fetchMovie(targetSource);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: movie.streamUrl,
|
||||
quality: movie.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
@@ -1,25 +1,33 @@
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
const netfilmBase = "https://net-film.vercel.app";
|
||||
|
||||
const qualityMap = {
|
||||
"360": MWStreamQuality.Q360P,
|
||||
"480": MWStreamQuality.Q480P,
|
||||
"720": MWStreamQuality.Q720P,
|
||||
"1080": MWStreamQuality.Q1080P,
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
type QualityInMap = keyof typeof qualityMap;
|
||||
|
||||
registerProvider({
|
||||
id: "netfilm",
|
||||
displayName: "NetFilm",
|
||||
rank: 15,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
disabled: true, // The creator has asked us (very nicely) to leave him alone. Until (if) we self-host, netfilm should remain disabled
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
// search for relevant item
|
||||
const searchResponse = await proxiedFetch<any>(
|
||||
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
|
||||
@@ -47,20 +55,29 @@ registerProvider({
|
||||
}
|
||||
);
|
||||
|
||||
const { qualities } = watchInfo.data;
|
||||
const data = watchInfo.data;
|
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
const source: { url: string; quality: number } = data.qualities.reduce(
|
||||
(p: any, c: any) => (c.quality > p.quality ? c : p)
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
needsProxy: false,
|
||||
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
|
||||
type: MWCaptionType.SRT,
|
||||
langIso: sub.language,
|
||||
}));
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality],
|
||||
type: MWStreamType.HLS,
|
||||
captions: [],
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -108,20 +125,29 @@ registerProvider({
|
||||
}
|
||||
);
|
||||
|
||||
const { qualities } = episodeStream.data;
|
||||
const data = episodeStream.data;
|
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
const source: { url: string; quality: number } = data.qualities.reduce(
|
||||
(p: any, c: any) => (c.quality > p.quality ? c : p)
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
needsProxy: false,
|
||||
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
|
||||
type: MWCaptionType.SRT,
|
||||
langIso: sub.language,
|
||||
}));
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality],
|
||||
type: MWStreamType.HLS,
|
||||
captions: [],
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@@ -225,15 +225,21 @@ registerProvider({
|
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||
|
||||
const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => {
|
||||
return {
|
||||
needsProxy: true,
|
||||
langIso: subtitle.language,
|
||||
url: subtitle.subtitles[0].file_path,
|
||||
type: MWCaptionType.SRT,
|
||||
};
|
||||
});
|
||||
|
||||
const mappedCaptions = subtitleRes.list.map(
|
||||
(subtitle: any): MWCaption | null => {
|
||||
const sub = subtitle;
|
||||
sub.subtitles = subtitle.subtitles.filter((subFile: any) => {
|
||||
const extension = subFile.file_path.slice(-3);
|
||||
return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension);
|
||||
});
|
||||
return {
|
||||
needsProxy: true,
|
||||
langIso: subtitle.language,
|
||||
url: sub.subtitles[0].file_path,
|
||||
type: MWCaptionType.SRT,
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
|
28
src/components/Banner.tsx
Normal file
28
src/components/Banner.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useBanner } from "@/hooks/useBanner";
|
||||
|
||||
export function Banner(props: { children: React.ReactNode; type: "error" }) {
|
||||
const [ref] = useBanner<HTMLDivElement>("internet");
|
||||
const styles = {
|
||||
error: "bg-[#C93957] text-white",
|
||||
};
|
||||
const icons = {
|
||||
error: Icons.CIRCLE_EXCLAMATION,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div
|
||||
className={[
|
||||
styles[props.type],
|
||||
"flex items-center justify-center p-1",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Icon icon={icons[props.type]} />
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
29
src/components/CaptionColorSelector.tsx
Normal file
29
src/components/CaptionColorSelector.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { Icon, Icons } from "./Icon";
|
||||
|
||||
export const colors = ["#ffffff", "#00ffff", "#ffff00"];
|
||||
export default function CaptionColorSelector({ color }: { color: string }) {
|
||||
const { captionSettings, setCaptionColor } = useSettings();
|
||||
return (
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${
|
||||
color === captionSettings.style.color ? "bg-[#1C161B]" : ""
|
||||
}`}
|
||||
onClick={() => setCaptionColor(color)}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
className={[
|
||||
"absolute text-xs text-[#1C161B]",
|
||||
color === captionSettings.style.color ? "" : "hidden",
|
||||
].join(" ")}
|
||||
icon={Icons.CHECKMARK}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:bottom-10 sm:text-sm">
|
||||
<Listbox.Options className="absolute top-10 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
|
@@ -34,6 +34,13 @@ export enum Icons {
|
||||
CAPTIONS = "captions",
|
||||
LINK = "link",
|
||||
CASTING = "casting",
|
||||
CIRCLE_EXCLAMATION = "circle_exclamation",
|
||||
DOWNLOAD = "download",
|
||||
GEAR = "gear",
|
||||
WATCH_PARTY = "watch_party",
|
||||
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||
CHECKMARK = "checkmark",
|
||||
TACHOMETER = "tachometer",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
@@ -72,9 +79,16 @@ const iconList: Record<Icons, string> = {
|
||||
skip_forward: `<svg width="1em" height="1em" viewBox="0 0 26 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.3333 12.3333L16 7.66667M16 7.66667L11.3333 3M16 7.66667H6.66667C5.42899 7.66667 4.242 8.15833 3.36684 9.0335C2.49167 9.90867 2 11.0957 2 12.3333C2 13.571 2.49167 14.758 3.36684 15.6332C4.242 16.5083 5.42899 17 6.66667 17H9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16.5043 14.2727V23H14.6591V16.0241H14.608L12.6094 17.277V15.6406L14.7699 14.2727H16.5043ZM22.0004 23.1918C21.2674 23.1889 20.6367 23.0085 20.1083 22.6506C19.5827 22.2926 19.1779 21.7741 18.8938 21.0952C18.6126 20.4162 18.4734 19.5994 18.4762 18.6449C18.4762 17.6932 18.6168 16.8821 18.8981 16.2116C19.1822 15.5412 19.587 15.0312 20.1126 14.6818C20.641 14.3295 21.2702 14.1534 22.0004 14.1534C22.7305 14.1534 23.3583 14.3295 23.8839 14.6818C24.4123 15.0341 24.8185 15.5455 25.1026 16.2159C25.3867 16.8835 25.5273 17.6932 25.5245 18.6449C25.5245 19.6023 25.3825 20.4205 25.0984 21.0994C24.8171 21.7784 24.4137 22.2969 23.8881 22.6548C23.3626 23.0128 22.7333 23.1918 22.0004 23.1918ZM22.0004 21.6619C22.5004 21.6619 22.8995 21.4105 23.1978 20.9077C23.4961 20.4048 23.6438 19.6506 23.641 18.6449C23.641 17.983 23.5728 17.4318 23.4364 16.9915C23.3029 16.5511 23.1126 16.2202 22.8654 15.9986C22.6211 15.777 22.3327 15.6662 22.0004 15.6662C21.5032 15.6662 21.1055 15.9148 20.8072 16.4119C20.5089 16.9091 20.3583 17.6534 20.3555 18.6449C20.3555 19.3153 20.4222 19.875 20.5558 20.3239C20.6921 20.7699 20.8839 21.1051 21.131 21.3295C21.3782 21.5511 21.668 21.6619 22.0004 21.6619Z" fill="currentColor" /></svg>`,
|
||||
skip_backward: `<svg width="1em" height="1em" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`,
|
||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
||||
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`,
|
||||
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 25 20"><path transform="translate(-3 -6)" d="M25.5,6H5.5A2.507,2.507,0,0,0,3,8.5v15A2.507,2.507,0,0,0,5.5,26h20A2.507,2.507,0,0,0,28,23.5V8.5A2.507,2.507,0,0,0,25.5,6ZM5.5,16h5v2.5h-5ZM18,23.5H5.5V21H18Zm7.5,0h-5V21h5Zm0-5H13V16H25.5Z" fill="currentColor"/></svg>`,
|
||||
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
|
||||
circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
|
||||
casting: "",
|
||||
download: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
|
||||
gear: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>`,
|
||||
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`,
|
||||
pictureInPicture: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/></svg>`,
|
||||
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
|
||||
tachometer: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 576 512"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M128 288c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zm154.65-97.08l16.24-48.71c1.16-3.45 3.18-6.35 4.92-9.43-4.73-2.76-9.94-4.78-15.81-4.78-17.67 0-32 14.33-32 32 0 15.78 11.63 28.29 26.65 30.92zM176 176c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm212.27 400H75.73C57.56 397.63 48 359.12 48 320 48 187.66 155.66 80 288 80s240 107.66 240 240c0 39.12-9.56 77.63-27.73 112zM416 320c0 17.67 14.33 32 32 32s32-14.33 32-32-14.33-32-32-32-32 14.33-32 32zm-56.41-182.77c-12.72-4.23-26.16 2.62-30.38 15.17l-45.34 136.01C250.49 290.58 224 318.06 224 352c0 11.72 3.38 22.55 8.88 32h110.25c5.5-9.45 8.88-20.28 8.88-32 0-19.45-8.86-36.66-22.55-48.4l45.34-136.01c4.17-12.57-2.64-26.17-15.21-30.36zM432 208c0-15.8-11.66-28.33-26.72-30.93-.07.21-.07.43-.14.65l-19.5 58.49c4.37 2.24 9.11 3.8 14.36 3.8 17.67-.01 32-14.34 32-32.01z"/></svg>`,
|
||||
};
|
||||
|
||||
function ChromeCastButton() {
|
||||
|
47
src/components/Slider.tsx
Normal file
47
src/components/Slider.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ChangeEventHandler, useEffect, useRef } from "react";
|
||||
|
||||
export type SliderProps = {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value?: number;
|
||||
valueDisplay?: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const e = ref.current as HTMLInputElement;
|
||||
e.style.setProperty("--value", e.value);
|
||||
e.style.setProperty("--min", e.min === "" ? "0" : e.min);
|
||||
e.style.setProperty("--max", e.max === "" ? "100" : e.max);
|
||||
e.addEventListener("input", () => e.style.setProperty("--value", e.value));
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-row gap-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{props.label ? (
|
||||
<label className="font-bold">{props.label}</label>
|
||||
) : null}
|
||||
<input
|
||||
type="range"
|
||||
ref={ref}
|
||||
className="styled-slider slider-progress mt-[20px]"
|
||||
onChange={props.onChange}
|
||||
value={props.value}
|
||||
max={props.max}
|
||||
min={props.min}
|
||||
step={props.step}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1">
|
||||
<div className="text-center font-bold text-white">
|
||||
{props.valueDisplay ?? props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -4,7 +4,13 @@ import {
|
||||
TransitionClasses,
|
||||
} from "@headlessui/react";
|
||||
|
||||
type TransitionAnimations = "slide-down" | "slide-up" | "fade" | "none";
|
||||
type TransitionAnimations =
|
||||
| "slide-down"
|
||||
| "slide-full-left"
|
||||
| "slide-full-right"
|
||||
| "slide-up"
|
||||
| "fade"
|
||||
| "none";
|
||||
|
||||
interface Props {
|
||||
show?: boolean;
|
||||
@@ -41,6 +47,28 @@ function getClasses(
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "slide-full-left") {
|
||||
return {
|
||||
leave: `transition-[transform] ${duration}`,
|
||||
leaveFrom: "translate-x-0",
|
||||
leaveTo: "-translate-x-full",
|
||||
enter: `transition-[transform] ${duration}`,
|
||||
enterFrom: "-translate-x-full",
|
||||
enterTo: "translate-x-0",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "slide-full-right") {
|
||||
return {
|
||||
leave: `transition-[transform] ${duration}`,
|
||||
leaveFrom: "translate-x-0",
|
||||
leaveTo: "translate-x-full",
|
||||
enter: `transition-[transform] ${duration}`,
|
||||
enterFrom: "translate-x-full",
|
||||
enterTo: "translate-x-0",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "fade") {
|
||||
return {
|
||||
leave: `transition-[transform,opacity] ${duration}`,
|
||||
|
@@ -1,7 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
export function BrandPill(props: { clickable?: boolean }) {
|
||||
export function BrandPill(props: {
|
||||
clickable?: boolean;
|
||||
hideTextOnMobile?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -13,7 +16,14 @@ export function BrandPill(props: { clickable?: boolean }) {
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
||||
<span className="font-semibold text-white">{t("global.name")}</span>
|
||||
<span
|
||||
className={[
|
||||
"font-semibold text-white",
|
||||
props.hideTextOnMobile ? "hidden sm:block" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("global.name")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -35,9 +35,14 @@ export function Modal(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalCard(props: { children?: ReactNode }) {
|
||||
export function ModalCard(props: { className?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10">
|
||||
<div
|
||||
className={[
|
||||
"relative mx-2 w-[500px] overflow-hidden rounded-lg bg-denim-300 px-10 py-10 sm:w-[500px] md:w-[500px] lg:w-[1000px]",
|
||||
props.className ?? "",
|
||||
].join(" ")}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
import SettingsModal from "@/views/SettingsModal";
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
@@ -11,45 +13,63 @@ export interface NavigationProps {
|
||||
}
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
const bannerHeight = useBannerSize();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-10 flex min-h-[88px] items-center justify-between py-5 px-7">
|
||||
<div
|
||||
className={`${
|
||||
props.bg ? "opacity-100" : "opacity-0"
|
||||
} absolute inset-0 block bg-denim-100 transition-opacity duration-300`}
|
||||
>
|
||||
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
|
||||
</div>
|
||||
<div className="relative flex w-full items-center justify-center sm:w-fit">
|
||||
<div className="mr-auto sm:mr-6">
|
||||
<Link to="/">
|
||||
<BrandPill clickable />
|
||||
</Link>
|
||||
<div
|
||||
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
|
||||
style={{
|
||||
top: `${bannerHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div className="fixed left-0 right-0 flex items-center justify-between py-5 px-7">
|
||||
<div
|
||||
className={`${
|
||||
props.bg ? "opacity-100" : "opacity-0"
|
||||
} absolute inset-0 block bg-denim-100 transition-opacity duration-300`}
|
||||
>
|
||||
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
props.children ? "hidden sm:flex" : "flex"
|
||||
} relative flex-row gap-4`}
|
||||
>
|
||||
<a
|
||||
href={conf().DISCORD_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
<div className="relative flex w-full items-center justify-center sm:w-fit">
|
||||
<div className="mr-auto sm:mr-6">
|
||||
<Link to="/">
|
||||
<BrandPill clickable />
|
||||
</Link>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
props.children ? "hidden sm:flex" : "flex"
|
||||
} relative flex-row gap-4`}
|
||||
>
|
||||
<IconPatch icon={Icons.DISCORD} clickable />
|
||||
</a>
|
||||
<a
|
||||
href={conf().GITHUB_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
>
|
||||
<IconPatch icon={Icons.GITHUB} clickable />
|
||||
</a>
|
||||
<IconPatch
|
||||
className="text-2xl text-white"
|
||||
icon={Icons.GEAR}
|
||||
clickable
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={conf().DISCORD_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
>
|
||||
<IconPatch icon={Icons.DISCORD} clickable />
|
||||
</a>
|
||||
<a
|
||||
href={conf().GITHUB_LINK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-2xl text-white"
|
||||
>
|
||||
<IconPatch icon={Icons.GITHUB} clickable />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -10,8 +10,8 @@ interface SectionHeadingProps {
|
||||
|
||||
export function SectionHeading(props: SectionHeadingProps) {
|
||||
return (
|
||||
<div className={`mt-12 ${props.className}`}>
|
||||
<div className="mb-4 flex items-end">
|
||||
<div className={props.className}>
|
||||
<div className="mb-5 flex items-center">
|
||||
<p className="flex flex-1 items-center font-bold uppercase text-denim-700">
|
||||
{props.icon ? (
|
||||
<span className="mr-2 text-xl">
|
||||
|
@@ -33,6 +33,9 @@ function MediaCardContent({
|
||||
|
||||
const canLink = linkable && !closable;
|
||||
|
||||
const dotListContent = [t(`media.${media.type}`)];
|
||||
if (media.year) dotListContent.push(media.year);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||
@@ -45,14 +48,27 @@ function MediaCardContent({
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100 group-hover:rounded-lg"
|
||||
className={[
|
||||
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
|
||||
closable ? "" : "group-hover:rounded-lg",
|
||||
].join(" ")}
|
||||
style={{
|
||||
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
||||
}}
|
||||
>
|
||||
{series ? (
|
||||
<div className="absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors group-hover:bg-denim-500">
|
||||
<p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white">
|
||||
<div
|
||||
className={[
|
||||
"absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors",
|
||||
closable ? "" : "group-hover:bg-denim-500",
|
||||
].join(" ")}
|
||||
>
|
||||
<p
|
||||
className={[
|
||||
"text-center text-xs font-bold text-slate-400 transition-colors",
|
||||
closable ? "" : "group-hover:text-white",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("seasons.seasonAndEpisode", {
|
||||
season: series.season,
|
||||
episode: series.episode,
|
||||
@@ -102,10 +118,7 @@ function MediaCardContent({
|
||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
||||
<span>{media.title}</span>
|
||||
</h1>
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[t(`media.${media.type}`), media.year]}
|
||||
/>
|
||||
<DotList className="text-xs" content={dotListContent} />
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
@@ -125,5 +138,9 @@ export function MediaCard(props: MediaCardProps) {
|
||||
)}`;
|
||||
|
||||
if (!props.linkable) return <span>{content}</span>;
|
||||
return <Link to={link}>{content}</Link>;
|
||||
return (
|
||||
<Link to={link} className={props.closable ? "hover:cursor-default" : ""}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
47
src/components/popout/FloatingAnchor.tsx
Normal file
47
src/components/popout/FloatingAnchor.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
export function createFloatingAnchorEvent(id: string): string {
|
||||
return `__floating::anchor::${id}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function FloatingAnchor(props: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const old = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
function render() {
|
||||
if (cancelled) return;
|
||||
|
||||
if (ref.current) {
|
||||
const current = old.current;
|
||||
const newer = ref.current.getBoundingClientRect();
|
||||
const newerStr = JSON.stringify(newer);
|
||||
if (current !== newerStr) {
|
||||
old.current = newerStr;
|
||||
const evtStr = createFloatingAnchorEvent(props.id);
|
||||
(window as any)[evtStr] = newer;
|
||||
const evObj = new CustomEvent(createFloatingAnchorEvent(props.id), {
|
||||
detail: newer,
|
||||
});
|
||||
document.dispatchEvent(evObj);
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(render);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
return <div ref={ref}>{props.children}</div>;
|
||||
}
|
192
src/components/popout/FloatingCard.tsx
Normal file
192
src/components/popout/FloatingCard.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
|
||||
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { PopoutSection } from "@/video/components/popouts/PopoutUtils";
|
||||
import { useSpringValue, animated, easings } from "@react-spring/web";
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Icon, Icons } from "../Icon";
|
||||
import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle";
|
||||
|
||||
interface FloatingCardProps {
|
||||
children?: ReactNode;
|
||||
onClose?: () => void;
|
||||
for: string;
|
||||
}
|
||||
|
||||
interface RootFloatingCardProps extends FloatingCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function CardBase(props: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { isMobile } = useIsMobile();
|
||||
const height = useSpringValue(0, {
|
||||
config: { easing: easings.easeInOutSine, duration: 300 },
|
||||
});
|
||||
const width = useSpringValue(0, {
|
||||
config: { easing: easings.easeInOutSine, duration: 300 },
|
||||
});
|
||||
const [pages, setPages] = useState<NodeListOf<Element> | null>(null);
|
||||
|
||||
const getNewHeight = useCallback(
|
||||
(updateList = true) => {
|
||||
if (!ref.current) return;
|
||||
const children = ref.current.querySelectorAll(
|
||||
":scope *[data-floating-page='true']"
|
||||
);
|
||||
if (updateList) setPages(children);
|
||||
if (children.length === 0) {
|
||||
height.start(0);
|
||||
width.start(0);
|
||||
return;
|
||||
}
|
||||
const lastChild = children[children.length - 1];
|
||||
const rect = lastChild.getBoundingClientRect();
|
||||
const rectHeight = lastChild.scrollHeight;
|
||||
if (height.get() === 0) {
|
||||
height.set(rectHeight);
|
||||
width.set(rect.width);
|
||||
} else {
|
||||
height.start(rectHeight);
|
||||
width.start(rect.width);
|
||||
}
|
||||
},
|
||||
[height, width]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
getNewHeight();
|
||||
const observer = new MutationObserver(() => {
|
||||
getNewHeight();
|
||||
});
|
||||
observer.observe(ref.current, {
|
||||
attributes: false,
|
||||
childList: true,
|
||||
subtree: false,
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [getNewHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
getNewHeight(false);
|
||||
});
|
||||
pages?.forEach((el) => observer.observe(el));
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [pages, getNewHeight]);
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
style={{
|
||||
height,
|
||||
width: isMobile ? "100%" : width,
|
||||
}}
|
||||
className="relative flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
{props.children}
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingCard(props: RootFloatingCardProps) {
|
||||
const { isMobile } = useIsMobile();
|
||||
const content = <CardBase>{props.children}</CardBase>;
|
||||
|
||||
if (isMobile)
|
||||
return (
|
||||
<FloatingCardMobilePosition
|
||||
className={props.className}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
{content}
|
||||
</FloatingCardMobilePosition>
|
||||
);
|
||||
|
||||
return (
|
||||
<FloatingCardAnchorPosition id={props.for} className={props.className}>
|
||||
{content}
|
||||
</FloatingCardAnchorPosition>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoutFloatingCard(props: FloatingCardProps) {
|
||||
return (
|
||||
<FloatingCard
|
||||
className="overflow-hidden rounded-md bg-ash-300"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const FloatingCardView = {
|
||||
Header(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
close?: boolean;
|
||||
goBack: () => any;
|
||||
action?: React.ReactNode;
|
||||
backText?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let left = (
|
||||
<div
|
||||
onClick={props.goBack}
|
||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
<Icon icon={Icons.ARROW_LEFT} />
|
||||
<span>{props.backText || t("videoPlayer.popouts.back")}</span>
|
||||
</div>
|
||||
);
|
||||
if (props.close)
|
||||
left = (
|
||||
<div
|
||||
onClick={props.goBack}
|
||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
<Icon icon={Icons.X} />
|
||||
<span>Close</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-[#1C161B]">
|
||||
<FloatingDragHandle />
|
||||
<PopoutSection>
|
||||
<div className="flex justify-between">
|
||||
<div>{left}</div>
|
||||
<div>{props.action ?? null}</div>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-8 mb-2 text-3xl font-bold text-white">
|
||||
{props.title}
|
||||
</h2>
|
||||
<p>{props.description}</p>
|
||||
</PopoutSection>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
Content(props: { children: React.ReactNode; noSection?: boolean }) {
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[1fr]">
|
||||
{props.noSection ? (
|
||||
<div className="relative h-full overflow-y-auto bg-ash-300">
|
||||
{props.children}
|
||||
</div>
|
||||
) : (
|
||||
<PopoutSection className="relative h-full overflow-y-auto bg-ash-300">
|
||||
{props.children}
|
||||
</PopoutSection>
|
||||
)}
|
||||
<MobilePopoutSpacer />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
75
src/components/popout/FloatingContainer.tsx
Normal file
75
src/components/popout/FloatingContainer.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Transition } from "@/components/Transition";
|
||||
import React, {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
onClose?: () => void;
|
||||
show?: boolean;
|
||||
darken?: boolean;
|
||||
}
|
||||
|
||||
export function FloatingContainer(props: Props) {
|
||||
const [portalElement, setPortalElement] = useState<Element | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const target = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function listen(e: MouseEvent) {
|
||||
target.current = e.target as Element;
|
||||
}
|
||||
document.addEventListener("mousedown", listen);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", listen);
|
||||
};
|
||||
});
|
||||
|
||||
const click = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const startedTarget = target.current;
|
||||
target.current = null;
|
||||
if (e.currentTarget !== e.target) return;
|
||||
if (!startedTarget) return;
|
||||
if (!startedTarget.isEqualNode(e.currentTarget as Element)) return;
|
||||
if (props.onClose) props.onClose();
|
||||
},
|
||||
[props]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current?.closest(".popout-location");
|
||||
setPortalElement(element ?? document.body);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{portalElement
|
||||
? createPortal(
|
||||
<Transition show={props.show} animation="none">
|
||||
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none">
|
||||
<Transition animation="fade" isChild>
|
||||
<div
|
||||
onClick={click}
|
||||
className={[
|
||||
"absolute inset-0",
|
||||
props.darken ? "bg-black opacity-90" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
</Transition>
|
||||
<Transition animation="slide-up" className="h-0" isChild>
|
||||
{props.children}
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>,
|
||||
portalElement
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
19
src/components/popout/FloatingDragHandle.tsx
Normal file
19
src/components/popout/FloatingDragHandle.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
|
||||
export function FloatingDragHandle() {
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
if (!isMobile) return null;
|
||||
|
||||
return (
|
||||
<div className="relative z-50 mx-auto my-3 -mb-3 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" />
|
||||
);
|
||||
}
|
||||
|
||||
export function MobilePopoutSpacer() {
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
if (!isMobile) return null;
|
||||
|
||||
return <div className="h-[200px]" />;
|
||||
}
|
40
src/components/popout/FloatingView.tsx
Normal file
40
src/components/popout/FloatingView.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
show?: boolean;
|
||||
className?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
active?: boolean; // true if a child view is loaded
|
||||
}
|
||||
|
||||
export function FloatingView(props: Props) {
|
||||
const { isMobile } = useIsMobile();
|
||||
const width = !isMobile ? `${props.width}px` : "100%";
|
||||
return (
|
||||
<Transition
|
||||
animation={props.active ? "slide-full-left" : "slide-full-right"}
|
||||
className="absolute inset-0"
|
||||
durationClass="duration-[400ms]"
|
||||
show={props.show}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
props.className ?? "",
|
||||
"grid grid-rows-[auto,minmax(0,1fr)]",
|
||||
].join(" ")}
|
||||
data-floating-page={props.show ? "true" : undefined}
|
||||
style={{
|
||||
height: props.height ? `${props.height}px` : undefined,
|
||||
maxHeight: "70vh",
|
||||
width: props.width ? width : undefined,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
}
|
@@ -0,0 +1,80 @@
|
||||
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface AnchorPositionProps {
|
||||
children?: ReactNode;
|
||||
id: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FloatingCardAnchorPosition(props: AnchorPositionProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [left, setLeft] = useState<number>(0);
|
||||
const [top, setTop] = useState<number>(0);
|
||||
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||
|
||||
const calculateAndSetCoords = useCallback(
|
||||
(anchor: DOMRect, card: DOMRect) => {
|
||||
const buttonCenter = anchor.left + anchor.width / 2;
|
||||
const bottomReal = window.innerHeight - anchor.bottom;
|
||||
|
||||
setTop(
|
||||
window.innerHeight - bottomReal - anchor.height - card.height - 30
|
||||
);
|
||||
setLeft(
|
||||
Math.min(
|
||||
buttonCenter - card.width / 2,
|
||||
window.innerWidth - card.width - 30
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchorRect || !cardRect) return;
|
||||
calculateAndSetCoords(anchorRect, cardRect);
|
||||
}, [anchorRect, calculateAndSetCoords, cardRect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
function checkBox() {
|
||||
const divRect = ref.current?.getBoundingClientRect();
|
||||
setCardRect(divRect ?? null);
|
||||
}
|
||||
checkBox();
|
||||
const observer = new ResizeObserver(checkBox);
|
||||
observer.observe(ref.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const evtStr = createFloatingAnchorEvent(props.id);
|
||||
if ((window as any)[evtStr]) setAnchorRect((window as any)[evtStr]);
|
||||
function listen(ev: CustomEvent<DOMRect>) {
|
||||
setAnchorRect(ev.detail);
|
||||
}
|
||||
document.addEventListener(evtStr, listen as any);
|
||||
return () => {
|
||||
document.removeEventListener(evtStr, listen as any);
|
||||
};
|
||||
}, [props.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
transform: `translateX(${left}px) translateY(${top}px)`,
|
||||
}}
|
||||
className={[
|
||||
"pointer-events-auto z-10 inline-block origin-top-left touch-none overflow-hidden",
|
||||
props.className ?? "",
|
||||
].join(" ")}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
103
src/components/popout/positions/FloatingCardMobilePosition.tsx
Normal file
103
src/components/popout/positions/FloatingCardMobilePosition.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useSpring, animated, config } from "@react-spring/web";
|
||||
import { useDrag } from "@use-gesture/react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface MobilePositionProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function FloatingCardMobilePosition(props: MobilePositionProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const closing = useRef<boolean>(false);
|
||||
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
|
||||
const [{ y }, api] = useSpring(() => ({
|
||||
y: 0,
|
||||
onRest() {
|
||||
if (!closing.current) return;
|
||||
if (props.onClose) props.onClose();
|
||||
},
|
||||
}));
|
||||
|
||||
const bind = useDrag(
|
||||
({
|
||||
last,
|
||||
velocity: [, vy],
|
||||
direction: [, dy],
|
||||
movement: [, my],
|
||||
...event
|
||||
}) => {
|
||||
if (closing.current) return;
|
||||
|
||||
const isInScrollable = (event.target as HTMLDivElement).closest(
|
||||
".overflow-y-auto"
|
||||
);
|
||||
if (isInScrollable) return; // Don't attempt to swipe the thing away if it's a scroll area unless the scroll area is at the top and the user is swiping down
|
||||
|
||||
const height = cardRect?.height ?? 0;
|
||||
if (last) {
|
||||
// if past half height downwards
|
||||
// OR Y velocity is past 0.5 AND going down AND 20 pixels below start position
|
||||
if (my > height * 0.5 || (vy > 0.5 && dy > 0 && my > 20)) {
|
||||
api.start({
|
||||
y: height * 1.2,
|
||||
immediate: false,
|
||||
config: { ...config.wobbly, velocity: vy, clamp: true },
|
||||
});
|
||||
closing.current = true;
|
||||
} else {
|
||||
api.start({
|
||||
y: 0,
|
||||
immediate: false,
|
||||
config: config.wobbly,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
api.start({ y: my, immediate: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
from: () => [0, y.get()],
|
||||
filterTaps: true,
|
||||
bounds: { top: 0 },
|
||||
rubberband: true,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
function checkBox() {
|
||||
const divRect = ref.current?.getBoundingClientRect();
|
||||
setCardRect(divRect ?? null);
|
||||
}
|
||||
checkBox();
|
||||
const observer = new ResizeObserver(checkBox);
|
||||
observer.observe(ref.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="is-mobile-view absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
window.innerHeight - (cardRect?.height ?? 0) + 200
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={[props.className ?? "", "touch-none"].join(" ")}
|
||||
style={{
|
||||
y,
|
||||
}}
|
||||
{...bind()}
|
||||
>
|
||||
{props.children}
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
}
|
61
src/hooks/useBanner.tsx
Normal file
61
src/hooks/useBanner.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useState,
|
||||
useMemo,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { useMeasure } from "react-use";
|
||||
|
||||
interface BannerInstance {
|
||||
id: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const BannerContext = createContext<
|
||||
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>]
|
||||
>(null as any);
|
||||
|
||||
export function BannerContextProvider(props: { children: ReactNode }) {
|
||||
const [state, setState] = useState<BannerInstance[]>([]);
|
||||
const memod = useMemo<
|
||||
[BannerInstance[], Dispatch<SetStateAction<BannerInstance[]>>]
|
||||
>(() => [state, setState], [state]);
|
||||
|
||||
return (
|
||||
<BannerContext.Provider value={memod}>
|
||||
{props.children}
|
||||
</BannerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBanner<T extends Element>(id: string) {
|
||||
const [ref, { height }] = useMeasure<T>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, set] = useContext(BannerContext);
|
||||
|
||||
useEffect(() => {
|
||||
set((v) => [...v, { id, height: 0 }]);
|
||||
set((value) => {
|
||||
const v = value.find((item) => item.id === id);
|
||||
if (v) {
|
||||
v.height = height;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
return () => {
|
||||
set((v) => v.filter((item) => item.id !== id));
|
||||
};
|
||||
}, [height, id, set]);
|
||||
|
||||
return [ref];
|
||||
}
|
||||
|
||||
export function useBannerSize() {
|
||||
const [val] = useContext(BannerContext);
|
||||
|
||||
return val.reduce((a, v) => a + v.height, 0);
|
||||
}
|
60
src/hooks/useFloatingRouter.ts
Normal file
60
src/hooks/useFloatingRouter.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
|
||||
export function useFloatingRouter(initial = "/") {
|
||||
const [route, setRoute] = useState<string[]>(
|
||||
initial.split("/").filter((v) => v.length > 0)
|
||||
);
|
||||
const [previousRoute, setPreviousRoute] = useState(route);
|
||||
const currentPage = route[route.length - 1] ?? "/";
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (previousRoute.length === route.length) return;
|
||||
// when navigating backwards, we delay the updating by a bit so transitions can be applied correctly
|
||||
setTimeout(() => {
|
||||
setPreviousRoute(route);
|
||||
}, 20);
|
||||
}, [route, previousRoute]);
|
||||
|
||||
function navigate(path: string) {
|
||||
const newRoute = path.split("/").filter((v) => v.length > 0);
|
||||
if (newRoute.length > previousRoute.length) setPreviousRoute(newRoute);
|
||||
setRoute(newRoute);
|
||||
}
|
||||
|
||||
function isActive(page: string) {
|
||||
if (page === "/") return true;
|
||||
const index = previousRoute.indexOf(page);
|
||||
if (index === -1) return false; // not active
|
||||
if (index === previousRoute.length - 1) return false; // active but latest route so shouldnt be counted as active
|
||||
return true;
|
||||
}
|
||||
|
||||
function isCurrentPage(page: string) {
|
||||
return page === currentPage;
|
||||
}
|
||||
|
||||
function isLoaded(page: string) {
|
||||
if (page === "/") return true;
|
||||
return route.includes(page);
|
||||
}
|
||||
|
||||
function pageProps(page: string) {
|
||||
return {
|
||||
show: isCurrentPage(page),
|
||||
active: isActive(page),
|
||||
};
|
||||
}
|
||||
|
||||
function reset() {
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
return {
|
||||
navigate,
|
||||
reset,
|
||||
isLoaded,
|
||||
isCurrentPage,
|
||||
pageProps,
|
||||
isActive,
|
||||
};
|
||||
}
|
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useIsMobile() {
|
||||
export function useIsMobile(horizontal?: boolean) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const isMobileCurrent = useRef<boolean | null>(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
const value = window.innerWidth < 1024;
|
||||
const value = horizontal
|
||||
? window.innerHeight < 600
|
||||
: window.innerWidth < 1024;
|
||||
const isChanged = isMobileCurrent.current !== value;
|
||||
if (!isChanged) return;
|
||||
|
||||
@@ -20,7 +22,7 @@ export function useIsMobile() {
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, []);
|
||||
}, [horizontal]);
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
|
41
src/hooks/usePing.ts
Normal file
41
src/hooks/usePing.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useIsOnline() {
|
||||
const [online, setOnline] = useState<boolean | null>(true);
|
||||
const ref = useRef<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
let counter = 0;
|
||||
|
||||
let abort: null | AbortController = null;
|
||||
const interval = setInterval(() => {
|
||||
// if online try once every 10 iterations intead of every iteration
|
||||
counter += 1;
|
||||
if (ref.current) {
|
||||
if (counter < 10) return;
|
||||
}
|
||||
counter = 0;
|
||||
|
||||
if (abort) abort.abort();
|
||||
abort = new AbortController();
|
||||
const signal = abort.signal;
|
||||
fetch("/ping.txt", { signal })
|
||||
.then(() => {
|
||||
setOnline(true);
|
||||
ref.current = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError") return;
|
||||
setOnline(false);
|
||||
ref.current = false;
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (abort) abort.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return online;
|
||||
}
|
@@ -6,7 +6,7 @@ function getInitialValue(params: { type: string; query: string }) {
|
||||
const type =
|
||||
Object.values(MWMediaType).find((v) => params.type === v) ||
|
||||
MWMediaType.MOVIE;
|
||||
const searchQuery = params.query || "";
|
||||
const searchQuery = decodeURIComponent(params.query || "");
|
||||
return { type, searchQuery };
|
||||
}
|
||||
|
||||
|
@@ -7,12 +7,12 @@ export function useVolumeControl(descriptor: string) {
|
||||
const controls = useControls(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
||||
const toggleVolume = () => {
|
||||
const toggleVolume = (isKeyboardEvent = false) => {
|
||||
if (mediaPlaying.volume > 0) {
|
||||
setStoredVolume(mediaPlaying.volume);
|
||||
controls.setVolume(0);
|
||||
controls.setVolume(0, isKeyboardEvent);
|
||||
} else {
|
||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1);
|
||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1, isKeyboardEvent);
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -1,10 +1,15 @@
|
||||
import React, { ReactNode, Suspense } from "react";
|
||||
import "core-js/stable";
|
||||
import React, { Suspense } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { BrowserRouter, HashRouter } from "react-router-dom";
|
||||
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
|
||||
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
||||
import { conf } from "@/setup/config";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import App from "@/setup/App";
|
||||
import "@/setup/ga";
|
||||
import "@/setup/sentry";
|
||||
import "@/setup/i18n";
|
||||
import "@/setup/index.css";
|
||||
import "@/backend";
|
||||
@@ -15,9 +20,12 @@ import { initializeStores } from "./utils/storage";
|
||||
const key =
|
||||
(window as any)?.__CONFIG__?.VITE_KEY ?? import.meta.env.VITE_KEY ?? null;
|
||||
if (key) {
|
||||
(window as any).initMW(conf().BASE_PROXY_URL, key);
|
||||
(window as any).initMW(conf().PROXY_URLS, key);
|
||||
}
|
||||
initializeChromecast();
|
||||
registerSW({
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const LazyLoadedApp = React.lazy(async () => {
|
||||
await initializeStores();
|
||||
|
@@ -1,33 +1,91 @@
|
||||
import { lazy } from "react";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||
import { WatchedContextProvider } from "@/state/watched";
|
||||
import { SettingsProvider } from "@/state/settings";
|
||||
|
||||
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
||||
import { MediaView } from "@/views/media/MediaView";
|
||||
import { SearchView } from "@/views/search/SearchView";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { V2MigrationView } from "@/views/other/v2Migration";
|
||||
import { BannerContextProvider } from "@/hooks/useBanner";
|
||||
import { Layout } from "@/setup/Layout";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<WatchedContextProvider>
|
||||
<BookmarkContextProvider>
|
||||
<Switch>
|
||||
<Route exact path="/v2-migration" component={V2MigrationView} />
|
||||
<Route exact path="/">
|
||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
||||
</Route>
|
||||
<Route exact path="/media/:media" component={MediaView} />
|
||||
<Route
|
||||
exact
|
||||
path="/media/:media/:season/:episode"
|
||||
component={MediaView}
|
||||
/>
|
||||
<Route exact path="/search/:type/:query?" component={SearchView} />
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
</BookmarkContextProvider>
|
||||
</WatchedContextProvider>
|
||||
<SettingsProvider>
|
||||
<WatchedContextProvider>
|
||||
<BookmarkContextProvider>
|
||||
<BannerContextProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{/* functional routes */}
|
||||
<Route exact path="/v2-migration" component={V2MigrationView} />
|
||||
<Route exact path="/">
|
||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
||||
</Route>
|
||||
|
||||
{/* pages */}
|
||||
<Route exact path="/media/:media" component={MediaView} />
|
||||
<Route
|
||||
exact
|
||||
path="/media/:media/:season/:episode"
|
||||
component={MediaView}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/search/:type/:query?"
|
||||
component={SearchView}
|
||||
/>
|
||||
|
||||
{/* other */}
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<>
|
||||
<Route
|
||||
exact
|
||||
path="/dev"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/DeveloperView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/test"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/TestView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/video"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/VideoTesterView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/providers"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/ProviderTesterView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/embeds"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/EmbedTesterView")
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
</BannerContextProvider>
|
||||
</BookmarkContextProvider>
|
||||
</WatchedContextProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
27
src/setup/Layout.tsx
Normal file
27
src/setup/Layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Banner } from "@/components/Banner";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
import { useIsOnline } from "@/hooks/usePing";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function Layout(props: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const isOnline = useIsOnline();
|
||||
const bannerSize = useBannerSize();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="fixed inset-x-0 z-[1000]">
|
||||
{!isOnline ? <Banner type="error">{t("errors.offline")}</Banner> : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
paddingTop: `${bannerSize}px`,
|
||||
}}
|
||||
className="flex min-h-screen flex-col"
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -10,8 +10,14 @@ interface Config {
|
||||
NORMAL_ROUTER: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig extends Config {
|
||||
BASE_PROXY_URL: string;
|
||||
export interface RuntimeConfig {
|
||||
APP_VERSION: string;
|
||||
GITHUB_LINK: string;
|
||||
DISCORD_LINK: string;
|
||||
OMDB_API_KEY: string;
|
||||
TMDB_API_KEY: string;
|
||||
NORMAL_ROUTER: boolean;
|
||||
PROXY_URLS: string[];
|
||||
}
|
||||
|
||||
const env: Record<keyof Config, undefined | string> = {
|
||||
@@ -27,12 +33,13 @@ const env: Record<keyof Config, undefined | string> = {
|
||||
const alerts = [] as string[];
|
||||
|
||||
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
|
||||
function getKey(key: keyof Config): string {
|
||||
function getKey(key: keyof Config, defaultString?: string): string {
|
||||
let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`];
|
||||
if (windowValue !== undefined && windowValue.length === 0)
|
||||
windowValue = undefined;
|
||||
const value = env[key] ?? windowValue ?? undefined;
|
||||
if (value === undefined) {
|
||||
if (defaultString) return defaultString;
|
||||
if (!alerts.includes(key)) {
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(`Misconfigured instance, missing key: ${key}`);
|
||||
@@ -51,8 +58,9 @@ export function conf(): RuntimeConfig {
|
||||
DISCORD_LINK,
|
||||
OMDB_API_KEY: getKey("OMDB_API_KEY"),
|
||||
TMDB_API_KEY: getKey("TMDB_API_KEY"),
|
||||
BASE_PROXY_URL: getKey("CORS_PROXY_URL"),
|
||||
CORS_PROXY_URL: `${getKey("CORS_PROXY_URL")}/?destination=`,
|
||||
NORMAL_ROUTER: (getKey("NORMAL_ROUTER") ?? "false") === "true",
|
||||
PROXY_URLS: getKey("CORS_PROXY_URL")
|
||||
.split(",")
|
||||
.map((v) => v.trim()),
|
||||
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
||||
};
|
||||
}
|
||||
|
@@ -1,3 +1,6 @@
|
||||
export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
|
||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
|
||||
export const APP_VERSION = "3.0.1";
|
||||
export const GA_ID = "G-44YVXRL61C";
|
||||
export const SENTRY_DSN =
|
||||
"https://b267ab7d52674c23af4e4e6cf2956251@o4505053491167232.ingest.sentry.io/4505053495296000";
|
||||
|
8
src/setup/ga.ts
Normal file
8
src/setup/ga.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import ReactGA from "react-ga4";
|
||||
import { GA_ID } from "@/setup/constants";
|
||||
|
||||
ReactGA.initialize([
|
||||
{
|
||||
trackingId: GA_ID,
|
||||
},
|
||||
]);
|
@@ -4,7 +4,26 @@ import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
// Languages
|
||||
import en from "./locales/en/translation.json";
|
||||
import nl from "./locales/nl/translation.json";
|
||||
import tr from "./locales/tr/translation.json";
|
||||
import fr from "./locales/fr/translation.json";
|
||||
|
||||
import { captionLanguages } from "./iso6391";
|
||||
|
||||
const locales = {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
nl: {
|
||||
translation: nl,
|
||||
},
|
||||
tr: {
|
||||
translation: tr,
|
||||
},
|
||||
fr: {
|
||||
translation: fr,
|
||||
},
|
||||
};
|
||||
i18n
|
||||
// detect user language
|
||||
// learn more: https://github.com/i18next/i18next-browser-languageDetector
|
||||
@@ -15,16 +34,14 @@ i18n
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
|
||||
resources: {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
},
|
||||
|
||||
resources: locales,
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
});
|
||||
|
||||
export const appLanguageOptions = captionLanguages.filter((x) => {
|
||||
return Object.keys(locales).includes(x.id);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
@@ -4,12 +4,13 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-denim-100 text-denim-700 font-open-sans overflow-x-hidden;
|
||||
@apply bg-denim-100 font-open-sans text-denim-700 overflow-x-hidden;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
html[data-full], html[data-full] body {
|
||||
html[data-full],
|
||||
html[data-full] body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ body[data-no-select] {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -53,3 +55,127 @@ body[data-no-select] {
|
||||
.google-cast-button:not(.casting) google-cast-launcher {
|
||||
@apply brightness-[500];
|
||||
}
|
||||
|
||||
.is-mobile-view .overflow-y-auto {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
/*generated with Input range slider CSS style generator (version 20211225)
|
||||
https://toughengineer.github.io/demo/slider-styler*/
|
||||
:root {
|
||||
--slider-height: 0.25rem;
|
||||
--slider-border-radius: 1em;
|
||||
--slider-progress-background: #8652bb;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider {
|
||||
height: var(--slider-height);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #1C161B;
|
||||
}
|
||||
|
||||
/*progress support*/
|
||||
input[type=range].styled-slider.slider-progress {
|
||||
--range: calc(var(--max) - var(--min));
|
||||
--ratio: calc((var(--value) - var(--min)) / var(--range));
|
||||
--sx: calc(0.5 * 1rem + var(--ratio) * (100% - 1rem));
|
||||
}
|
||||
|
||||
/*webkit*/
|
||||
input[type=range].styled-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #FFFFFF;
|
||||
border: none;
|
||||
box-shadow: 0 0 2px #000000;
|
||||
margin-top: calc(0.25em * 0.5 - 1rem * 0.5);
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-webkit-slider-runnable-track {
|
||||
height: var(--slider-height);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: var(--slider-border-radius);
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-webkit-slider-thumb:hover {
|
||||
background: #DCDCDC;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider.slider-progress::-webkit-slider-runnable-track {
|
||||
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||
}
|
||||
|
||||
/*mozilla*/
|
||||
input[type=range].styled-slider::-moz-range-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #FFFFFF;
|
||||
border: none;
|
||||
box-shadow: 0 0 2px #000000;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-moz-range-track {
|
||||
height: var(--slider-height);
|
||||
border: none;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #1C161B;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-moz-range-thumb:hover {
|
||||
background: #DCDCDC;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider.slider-progress::-moz-range-track {
|
||||
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||
}
|
||||
|
||||
/*ms*/
|
||||
input[type=range].styled-slider::-ms-fill-upper {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-ms-fill-lower {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-ms-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #FFFFFF;
|
||||
border: none;
|
||||
box-shadow: 0 0 2px #000000;
|
||||
margin-top: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-ms-track {
|
||||
height: var(--slider-height);
|
||||
border-radius: var(--slider-border-radius);
|
||||
background: #1C161B;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider::-ms-thumb:hover {
|
||||
background: #DCDCDC;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider.slider-progress::-ms-fill-lower {
|
||||
height: var(--slider-height);
|
||||
border-radius: var(--slider-border-radius) 0 0 5px;
|
||||
margin: -undefined 0 -undefined -undefined;
|
||||
background: var(--slider-progress-background);
|
||||
border: none;
|
||||
border-right-width: 0;
|
||||
}
|
1326
src/setup/iso6391.ts
Normal file
1326
src/setup/iso6391.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -55,29 +55,63 @@
|
||||
"noVideos": "Whoops, couldn't find any videos for you",
|
||||
"loading": "Loading...",
|
||||
"backToHome": "Back to home",
|
||||
"backToHomeShort": "Back",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} left",
|
||||
"finishAt": "Finish at {{timeFinished}}",
|
||||
"buttons": {
|
||||
"episodes": "Episodes",
|
||||
"source": "Source",
|
||||
"captions": "Captions"
|
||||
"captions": "Captions",
|
||||
"download": "Download",
|
||||
"settings": "Settings",
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"playbackSpeed": "Playback speed"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Go back",
|
||||
"sources": "Sources",
|
||||
"seasons": "Seasons",
|
||||
"captions": "Captions",
|
||||
"playbackSpeed": "Playback speed",
|
||||
"customPlaybackSpeed": "Custom playback speed",
|
||||
"captionPreferences": {
|
||||
"title": "Customize",
|
||||
"delay": "Delay",
|
||||
"fontSize": "Size",
|
||||
"opacity": "Opacity",
|
||||
"color": "Color"
|
||||
},
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "No captions",
|
||||
"linkedCaptions": "Linked captions",
|
||||
"customCaption": "Custom caption",
|
||||
"uploadCustomCaption": "Upload caption",
|
||||
"noEmbeds": "No embeds were found for this source",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
|
||||
"embedsError": "Something went wrong loading the embeds for this thing that you like"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "What provider do you want to use?",
|
||||
"embeds": "Choose which video to view",
|
||||
"seasons": "Choose which season you want to watch",
|
||||
"episode": "Pick an episode",
|
||||
"captions": "Choose a subtitle language",
|
||||
"captionPreferences": "Make subtitles look how you want it",
|
||||
"playbackSpeed": "Change the playback speed"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"captionLanguage": "Caption Language"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "New version now released!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
@@ -87,5 +121,8 @@
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Casting to device..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Check your internet connection"
|
||||
}
|
||||
}
|
||||
|
127
src/setup/locales/fr/translation.json
Normal file
127
src/setup/locales/fr/translation.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Recherche de votre série préférée...",
|
||||
"loading_movie": "Recherche de vos films préférés...",
|
||||
"loading": "Chargement...",
|
||||
"allResults": "C'est tout ce que nous avons!",
|
||||
"noResults": "Nous n'avons rien trouvé!",
|
||||
"allFailed": "Le média n'a pas été trouvé, veuillez réessayez!",
|
||||
"headingTitle": "Résultats de la recherche",
|
||||
"bookmarks": "Favoris",
|
||||
"continueWatching": "Continuer le visionnage",
|
||||
"title": "Que voulez-vous voir?",
|
||||
"placeholder": "Que voulez-vous voir?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Films",
|
||||
"series": "Séries",
|
||||
"stopEditing": "Arrêter l'édition",
|
||||
"errors": {
|
||||
"genericTitle": "Oups, c'est coupé !",
|
||||
"failedMeta": "Impossible de charger les métadonnées",
|
||||
"mediaFailed": "Nous n'avons pas réussi à récupérer le média que vous avez demandé. Veuillez vérifier votre connexion Internet et réessayer.",
|
||||
"videoFailed": "Nous avons rencontré une erreur lors de la lecture de la vidéo que vous avez demandée. Si cela se reproduit, veuillez signaler le problème au serveur <0>Discord</0> ou sur <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{saison}} E{{épisode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Introuvable",
|
||||
"backArrow": "Retour à l'accueil",
|
||||
"media": {
|
||||
"title": "Impossible de trouver ce média",
|
||||
"description": "Nous n'avons pas trouvé le média que vous avez demandé. Soit il a été supprimé, soit vous avez modifié l'URL."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Ce fournisseur a été désactivé",
|
||||
"description": "Nous avons eu des problèmes avec le fournisseur ou il était trop instable pour être utilisé, nous avons donc dû le désactiver."
|
||||
},
|
||||
"page": {
|
||||
"title": "Impossible de trouver cette page",
|
||||
"description": "Nous avons cherché partout : sous les poubelles, dans le placard, derrière le proxy, mais nous n'avons finalement pas trouvé la page que vous cherchez."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Film",
|
||||
"series": "Série",
|
||||
"Search": "Rechercher"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "Recherche de la meilleure vidéo pour vous",
|
||||
"noVideos": "Désolé, nous n'avons pas pu trouver de vidéos pour vous",
|
||||
"loading": "Chargement...",
|
||||
"backToHome": "Retour à la page d'accueil",
|
||||
"backToHomeShort": "Retour",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} restant",
|
||||
"finishAt": "Terminer à {{timeFinished}}",
|
||||
"buttons": {
|
||||
"episodes": "Épisodes",
|
||||
"source": "Source",
|
||||
"captions": "Sous-titres",
|
||||
"download": "Télécharger",
|
||||
"settings": "Paramètres",
|
||||
"pictureInPicture": "Image dans l'image",
|
||||
"playbackSpeed": "Vitesse"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Retourner",
|
||||
"sources": "Sources",
|
||||
"seasons": "Saisons",
|
||||
"captions": "Sous-titres",
|
||||
"playbackSpeed": "Vitesse de lecture",
|
||||
"customPlaybackSpeed": "Vitesse de lecture personnalisée",
|
||||
"captionPreferences": {
|
||||
"title": "Personnaliser",
|
||||
"delay": "Délai",
|
||||
"fontSize": "Taille",
|
||||
"opacity": "Opacité",
|
||||
"color": "Couleur"
|
||||
},
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "Pas de sous-titres",
|
||||
"linkedCaptions": "Sous-titres liés",
|
||||
"customCaption": "Sous-titres personnalisés",
|
||||
"uploadCustomCaption": "Télécharger des sous-titres",
|
||||
"noEmbeds": "Aucun contenu intégré n'a été trouvé pour cette source",
|
||||
"errors": {
|
||||
"loadingWentWong": "Un problème est survenu lors du chargement des épisodes pour {{seasonTitle}}",
|
||||
"embedsError": "Un problème est survenu lors du chargement des contenus intégrés pour cet élément que vous aimez"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Quel fournisseur voulez-vous utiliser ?",
|
||||
"embeds": "Choisissez quelle vidéo regarder",
|
||||
"seasons": "Choisissez la saison que vous voulez regarder",
|
||||
"episode": "Sélectionnez un épisode",
|
||||
"captions": "Choisissez une langue de sous-titres",
|
||||
"captionPreferences": "Personnalisez l'apparence des sous-titres",
|
||||
"playbackSpeed": "Changer la vitesse de lecture"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "Le lecteur vidéo a rencontré une erreur fatale, veuillez la signaler au serveur <0>Discord</0> ou sur <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"language": "Language",
|
||||
"captionLanguage": "Langue des sous-titres"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "Nouvelle version disponible!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "movie-web déménagera bientôt vers un nouveau domaine : <0>https://movie-web.app</0>. Veillez à mettre à jour tous vos favoris car <1>l'ancien site web cessera de fonctionner le {{date}}.</1>",
|
||||
"tireless": "Nous avons travaillé sans relâche sur cette nouvelle mise à jour et nous espérons que vous apprécierez ce que nous avons préparé ces derniers mois.",
|
||||
"leaveAnnouncement": "Emmenez-moi là!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Transmission à l'appareil..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Vérifiez votre connexion internet"
|
||||
}
|
||||
}
|
128
src/setup/locales/nl/translation.json
Normal file
128
src/setup/locales/nl/translation.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "We zoeken je favoriete series...",
|
||||
"loading_movie": "We zoeken je favoriete films...",
|
||||
"loading": "Aan het zoeken...",
|
||||
"allResults": "Dat is het!",
|
||||
"noResults": "We konden helaas niets vinden.",
|
||||
"allFailed": "Het is niet gelukt de media te laden, probeer het nog eens.",
|
||||
"headingTitle": "Zoekresultaten",
|
||||
"bookmarks": "Opgeslagen",
|
||||
"continueWatching": "Kijk verder",
|
||||
"title": "Wat wil je graag kijken?",
|
||||
"placeholder": "Wat wil je graag kijken?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Film",
|
||||
"series": "Serie",
|
||||
"stopEditing": "Stop met bewerken",
|
||||
"errors": {
|
||||
"genericTitle": "Oeps, hier ging iets mis!",
|
||||
"failedMeta": "Het is niet gelukt de meta-informatie op te halen/",
|
||||
"mediaFailed": "Het is niet gelukt deze media op te halen. Controleer of je een internetverbinding hebt en probeer het nog een keer.",
|
||||
"videoFailed": "Er ging iets mis tijdens het spelen van deze video. Als dit blijft gebeuren, deel het dan in de <0>Discord server</0> of maak een <1>GitHub issue</1>."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} A{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Pagina niet gevonden",
|
||||
"backArrow": "Naar de home-pagina",
|
||||
"media": {
|
||||
"title": "We konden deze media niet vinden.",
|
||||
"description": "We konden dit stukje media niet vinden. Het is mogelijk verwijderd, of jij hebt zelf de URL aangepast."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Deze bron is niet langer beschikbaar",
|
||||
"description": "Deze bron was helaas te instabiel, we hebben hem jammer genoeg uit moeten zetten."
|
||||
},
|
||||
"page": {
|
||||
"title": "Pagina niet gevonden",
|
||||
"description": "We hebben echt alles geprobeerd, zelfs tijdrijzen; echter hebben we deze pagina helaas niet kunnen vinden."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Films",
|
||||
"series": "Series",
|
||||
"Search": "Zoeken"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "De beste video voor jou aan het zoeken...",
|
||||
"noVideos": "Helaas konden we dat filmpje niet vinden",
|
||||
"loading": "Aan het laden...",
|
||||
"backToHome": "Naar de home-pagina",
|
||||
"backToHomeShort": "Terug",
|
||||
"seasonAndEpisode": "S{{season}} A{{episode}}",
|
||||
"timeLeft": "Nog {{timeLeft}}",
|
||||
"finishAt": "Afgelopen om {{timeFinished}}",
|
||||
"buttons": {
|
||||
"episodes": "Afleveringen",
|
||||
"source": "Bron",
|
||||
"captions": "Ondertiteling",
|
||||
"download": "Download",
|
||||
"settings": "Instellingen",
|
||||
"pictureInPicture": "Beeld-in-beeld",
|
||||
"playbackSpeed": "Afspeelsnelheid"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Terug",
|
||||
"sources": "Bronnen",
|
||||
"seasons": "Seizoenen",
|
||||
"captions": "Ondertiteling",
|
||||
"playbackSpeed": "Afspeelsnelheid",
|
||||
"customPlaybackSpeed": "Andere snelheden",
|
||||
"captionPreferences": {
|
||||
"title": "Instellingen",
|
||||
"delay": "Vertraging",
|
||||
"fontSize": "Lettergrootte",
|
||||
"opacity": "Doorzichtbaarheid",
|
||||
"color": "Kleur"
|
||||
},
|
||||
"episode": "A{{index}} - {{title}}",
|
||||
"noCaptions": "Geen ondertiteling",
|
||||
"linkedCaptions": "Gelinkte ondertiteling",
|
||||
"customCaption": "Eigen ondertiteling",
|
||||
"uploadCustomCaption": "Ondertiteling uploaden",
|
||||
"noEmbeds": "We hebben geen filmpjes kunnen vinden voor deze bron.",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "Er ging iets mis tijdens het laden van de afleveringen voor {{seasonTitle}}",
|
||||
"embedsError": "Er ging iets mis tijdens het laden van de embeds voor dit dingetje dat je waarschijnlijk leuk vindt"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Welke bron wil je graag gebruiken",
|
||||
"embeds": "Welk filmpje wil je gebruiken?",
|
||||
"seasons": "Welk seizoen wil je kijken?",
|
||||
"episode": "Kies een aflevering",
|
||||
"captions": "Kies een taal voor de ondertiteling",
|
||||
"captionPreferences": "Pas de ondertiteling aan aan je voorkeuren",
|
||||
"playbackSpeed": "Pas de afspeelsnelhijd aan"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "De videospeler is helaas ontploft, rapporteer deze fout op de <0>Discord server</0> of op <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Instellingen",
|
||||
"language": "Taal",
|
||||
"captionLanguage": "Taal voor de Ondertiteling"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "De nieuwe versie is uit!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "We gaan binnenkort verhuizen naar een nieuw domein: <0>https://movie-web.app</0>. Pas je bladwijzers aan naar het nieuwe domein, want </b>het oude domein gaat stoppen met werken op {{date}}.</b>",
|
||||
"tireless": "We hebben mega hard gewerkt aan deze nieuwe versie, dus we hopen dat je er van gaat genieten.",
|
||||
"leaveAnnouncement": "Let's go!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Aan het casten..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Controleer je internetverbinding"
|
||||
}
|
||||
}
|
128
src/setup/locales/tr/translation.json
Normal file
128
src/setup/locales/tr/translation.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Favori dizileriniz aranıyor...",
|
||||
"loading_movie": "Favori filmleriniz aranıyor...",
|
||||
"loading": "Yükleniyor...",
|
||||
"allResults": "Bu kadarını bulabildik!",
|
||||
"noResults": "Hiçbir şey bulamadık!",
|
||||
"allFailed": "Medya bulunamadı, tekrar deneyin!",
|
||||
"headingTitle": "Arama sonuçları",
|
||||
"bookmarks": "Yerimleri",
|
||||
"continueWatching": "İzlemeye devam edin",
|
||||
"title": "Ne izlemek istersiniz?",
|
||||
"placeholder": "Ne izlemek istersiniz?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Film",
|
||||
"series": "Dizi",
|
||||
"stopEditing": "Düzenlemeyi durdur",
|
||||
"errors": {
|
||||
"genericTitle": "Hay aksi, bozuldu!",
|
||||
"failedMeta": "Önbilgi yüklenemedi",
|
||||
"mediaFailed": "İstediğiniz medyaya istek atarken hata oluştu, internet bağlantınızı kontrol edin ve tekrar deneyin.",
|
||||
"videoFailed": "İstediğiniz videoyu oynatırken bir sorunla karşılaştık. Bu durum devam ederse lütfen bunu <0>Discord sunucumuza</0> veya <1>GitHub</1> üzerinden bildiriniz."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} B{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Bulunamadı",
|
||||
"backArrow": "Geri",
|
||||
"media": {
|
||||
"title": "Medya bulunamadı",
|
||||
"description": "İstediğiniz medyayı bulamadık. URL'i yanlış girdiniz ya da medya kaldırıldı."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Bu sağlayıcı devre dışı bırakıldı",
|
||||
"description": "Sağlayıcı ile ilgili bir sorun oluştu ya da kullanılacak kadar stabil değildi bu yüzden devre dışı bırakmak zorunda kaldık."
|
||||
},
|
||||
"page": {
|
||||
"title": "Sayfa bulunamadı",
|
||||
"description": "Her yere baktık: bazanın altına, dolabın içine hatta ara sunucuya ama maalesef aradığınız sayfayı bulamadık."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Film",
|
||||
"series": "Dizi",
|
||||
"Search": "Ara"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "Sizin için en iyi videoyu buluyoruz...",
|
||||
"noVideos": "Hay aksi, hiçbir video bulamadık",
|
||||
"loading": "Yükleniyor...",
|
||||
"backToHome": "Ana sayfaya dön",
|
||||
"backToHomeShort": "Geri",
|
||||
"seasonAndEpisode": "S{{season}} B{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} kaldı",
|
||||
"finishAt": "{{timeFinished, datetime}}'de/da bitiyor",
|
||||
"buttons": {
|
||||
"episodes": "Bölümler",
|
||||
"source": "Kaynak",
|
||||
"captions": "Altyazılar",
|
||||
"download": "İndir",
|
||||
"settings": "Ayarlar",
|
||||
"pictureInPicture": "Resim içinde Resim",
|
||||
"playbackSpeed": "Oynatma Hızı"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Geri git",
|
||||
"sources": "Kaynaklar",
|
||||
"seasons": "Sezonlar",
|
||||
"captions": "Altyazılar",
|
||||
"playbackSpeed": "Oynatma hızı",
|
||||
"customPlaybackSpeed": "Özel oynatma hızı",
|
||||
"captionPreferences": {
|
||||
"title": "Kişiselleştirme",
|
||||
"delay": "Gecikme",
|
||||
"fontSize": "Boyut",
|
||||
"opacity": "Opaklık",
|
||||
"color": "Renk"
|
||||
},
|
||||
"episode": "B{{index}} - {{title}}",
|
||||
"noCaptions": "Altyazı yok",
|
||||
"linkedCaptions": "Kaynak Altyazıları",
|
||||
"customCaption": "Özel altyazı",
|
||||
"uploadCustomCaption": "Altyazı yükle",
|
||||
"noEmbeds": "Bu kaynak için gömülü video bulunamadı",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "{{seasonTitle}} için bölümler yüklenirken bir hata oluştu",
|
||||
"embedsError": "İstediğiniz şey için gömülü video bulunurken bir hata oluştu"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Hangi sağlayıcıyı kullanmak istersiniz?",
|
||||
"embeds": "Görüntülemek istediğiniz videoyu seçiniz",
|
||||
"seasons": "İzlemek istediğiniz sezonu seçiniz",
|
||||
"episode": "Bir bölüm seçiniz",
|
||||
"captions": "Altyazı dili seçiniz",
|
||||
"captionPreferences": "Altyazıları istediğiniz gibi ayarlayın",
|
||||
"playbackSpeed": "Oynatma hızınızı değiştirin"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "Video oynatıcıda bir hata oluştu, lütfen bunu <0>Discord sunucumuzda</0> ya da <1>GitHub</1> üzeriden bildiriniz."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
"language": "Dil",
|
||||
"captionLanguage": "Altyazı Dili"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "Yeni sürüm yayınlandı!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "movie-web yakında yeni bir alan adına taşınacak: <0>https://movie-web.app</0>. <1>{{date}} tarihinde eski site çalışmayacağı için</1> yerimlerinizi güncellemeyi unutmayın.",
|
||||
"tireless": "Bu yeni güncelleme için gece gündüz çalıştık, umarız aylardan beri hazırladığımız bu güncellemeyi beğenirsiniz.",
|
||||
"leaveAnnouncement": "Götür beni!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Cihaza aktarılıyor..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "İnternet bağlantınızı kontrol ediniz"
|
||||
}
|
||||
}
|
15
src/setup/sentry.tsx
Normal file
15
src/setup/sentry.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { CaptureConsole, HttpClient } from "@sentry/integrations";
|
||||
import { SENTRY_DSN } from "@/setup/constants";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
release: `movie-web@${conf().APP_VERSION}`,
|
||||
sampleRate: 0.5,
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing(),
|
||||
new CaptureConsole(),
|
||||
new HttpClient(),
|
||||
],
|
||||
});
|
90
src/state/settings/context.tsx
Normal file
90
src/state/settings/context.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useStore } from "@/utils/storage";
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
||||
import { LangCode } from "@/setup/iso6391";
|
||||
import { SettingsStore } from "./store";
|
||||
import { MWSettingsData } from "./types";
|
||||
|
||||
interface MWSettingsDataSetters {
|
||||
setLanguage(language: LangCode): void;
|
||||
setCaptionLanguage(language: LangCode): void;
|
||||
setCaptionDelay(delay: number): void;
|
||||
setCaptionColor(color: string): void;
|
||||
setCaptionFontSize(size: number): void;
|
||||
setCaptionBackgroundColor(backgroundColor: number): void;
|
||||
}
|
||||
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
|
||||
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
|
||||
export function SettingsProvider(props: { children: ReactNode }) {
|
||||
function enforceRange(min: number, value: number, max: number) {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
const [settings, setSettings] = useStore(SettingsStore);
|
||||
const context: MWSettingsDataWrapper = useMemo(() => {
|
||||
const settingsContext: MWSettingsDataWrapper = {
|
||||
...settings,
|
||||
setLanguage(language) {
|
||||
setSettings((oldSettings) => {
|
||||
return {
|
||||
...oldSettings,
|
||||
language,
|
||||
};
|
||||
});
|
||||
},
|
||||
setCaptionLanguage(language) {
|
||||
setSettings((oldSettings) => {
|
||||
const captionSettings = oldSettings.captionSettings;
|
||||
captionSettings.language = language;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionDelay(delay: number) {
|
||||
setSettings((oldSettings) => {
|
||||
const captionSettings = oldSettings.captionSettings;
|
||||
captionSettings.delay = enforceRange(-10, delay, 10);
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionColor(color) {
|
||||
setSettings((oldSettings) => {
|
||||
const style = oldSettings.captionSettings.style;
|
||||
style.color = color;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionFontSize(size) {
|
||||
setSettings((oldSettings) => {
|
||||
const style = oldSettings.captionSettings.style;
|
||||
style.fontSize = enforceRange(10, size, 60);
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionBackgroundColor(backgroundColor) {
|
||||
setSettings((oldSettings) => {
|
||||
const style = oldSettings.captionSettings.style;
|
||||
style.backgroundColor = `${style.backgroundColor.substring(
|
||||
0,
|
||||
7
|
||||
)}${backgroundColor.toString(16).padStart(2, "0")}`;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
};
|
||||
return settingsContext;
|
||||
}, [settings, setSettings]);
|
||||
return (
|
||||
<SettingsContext.Provider value={context}>
|
||||
{props.children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return useContext(SettingsContext);
|
||||
}
|
||||
|
||||
export default SettingsContext;
|
1
src/state/settings/index.ts
Normal file
1
src/state/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./context";
|
48
src/state/settings/store.ts
Normal file
48
src/state/settings/store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createVersionedStore } from "@/utils/storage";
|
||||
import { MWSettingsData, MWSettingsDataV1 } from "./types";
|
||||
|
||||
export const SettingsStore = createVersionedStore<MWSettingsData>()
|
||||
.setKey("mw-settings")
|
||||
.addVersion({
|
||||
version: 0,
|
||||
create(): MWSettingsDataV1 {
|
||||
return {
|
||||
language: "en",
|
||||
captionSettings: {
|
||||
delay: 0,
|
||||
style: {
|
||||
color: "#ffffff",
|
||||
fontSize: 25,
|
||||
backgroundColor: "#00000096",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
migrate(data: MWSettingsDataV1): MWSettingsData {
|
||||
return {
|
||||
language: data.language,
|
||||
captionSettings: {
|
||||
language: "none",
|
||||
...data.captionSettings,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.addVersion({
|
||||
version: 1,
|
||||
create(): MWSettingsData {
|
||||
return {
|
||||
language: "en",
|
||||
captionSettings: {
|
||||
delay: 0,
|
||||
language: "none",
|
||||
style: {
|
||||
color: "#ffffff",
|
||||
fontSize: 25,
|
||||
backgroundColor: "#00000096",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.build();
|
36
src/state/settings/types.ts
Normal file
36
src/state/settings/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { LangCode } from "@/setup/iso6391";
|
||||
|
||||
export interface CaptionStyleSettings {
|
||||
color: string;
|
||||
/**
|
||||
* Range is [10, 30]
|
||||
*/
|
||||
fontSize: number;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export interface CaptionSettingsV1 {
|
||||
/**
|
||||
* Range is [-10, 10]s
|
||||
*/
|
||||
delay: number;
|
||||
style: CaptionStyleSettings;
|
||||
}
|
||||
|
||||
export interface CaptionSettings {
|
||||
language: LangCode;
|
||||
/**
|
||||
* Range is [-10, 10]s
|
||||
*/
|
||||
delay: number;
|
||||
style: CaptionStyleSettings;
|
||||
}
|
||||
export interface MWSettingsDataV1 {
|
||||
language: LangCode;
|
||||
captionSettings: CaptionSettingsV1;
|
||||
}
|
||||
|
||||
export interface MWSettingsData {
|
||||
language: LangCode;
|
||||
captionSettings: CaptionSettings;
|
||||
}
|
@@ -38,3 +38,11 @@ export function canWebkitFullscreen(): boolean {
|
||||
export function canFullscreen(): boolean {
|
||||
return canFullscreenAnyElement() || canWebkitFullscreen();
|
||||
}
|
||||
|
||||
export function canPictureInPicture(): boolean {
|
||||
return "pictureInPictureEnabled" in document;
|
||||
}
|
||||
|
||||
export function canWebkitPictureInPicture(): boolean {
|
||||
return "webkitSupportsPresentationMode" in document.createElement("video");
|
||||
}
|
||||
|
7
src/utils/normalizeTitle.ts
Normal file
7
src/utils/normalizeTitle.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['":]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_");
|
||||
}
|
@@ -99,7 +99,7 @@ function buildStorageObject<T>(store: InternalStoreData): StoreRet<T> {
|
||||
localStorage.setItem(key, JSON.stringify(withVersion));
|
||||
|
||||
if (!storeCallbacks[key]) storeCallbacks[key] = [];
|
||||
storeCallbacks[key].forEach((v) => v(structuredClone(data)));
|
||||
storeCallbacks[key].forEach((v) => v(window.structuredClone(data)));
|
||||
}
|
||||
|
||||
return {
|
||||
|
@@ -1,10 +1,4 @@
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['":]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_");
|
||||
}
|
||||
import { normalizeTitle } from "./normalizeTitle";
|
||||
|
||||
export function compareTitle(a: string, b: string): boolean {
|
||||
return normalizeTitle(a) === normalizeTitle(b);
|
||||
|
@@ -10,10 +10,7 @@ import { MobileCenterAction } from "@/video/components/actions/MobileCenterActio
|
||||
import { PageTitleAction } from "@/video/components/actions/PageTitleAction";
|
||||
import { PauseAction } from "@/video/components/actions/PauseAction";
|
||||
import { ProgressAction } from "@/video/components/actions/ProgressAction";
|
||||
import { QualityDisplayAction } from "@/video/components/actions/QualityDisplayAction";
|
||||
import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction";
|
||||
import { SourceSelectionAction } from "@/video/components/actions/SourceSelectionAction";
|
||||
import { CaptionsSelectionAction } from "@/video/components/actions/CaptionsSelectionAction";
|
||||
import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction";
|
||||
import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction";
|
||||
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
|
||||
@@ -30,6 +27,11 @@ import { ReactNode, useCallback, useState } from "react";
|
||||
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
|
||||
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
|
||||
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
|
||||
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
|
||||
import { SettingsAction } from "./actions/SettingsAction";
|
||||
import { DividerAction } from "./actions/DividerAction";
|
||||
import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
|
||||
|
||||
type Props = VideoPlayerBaseProps;
|
||||
|
||||
@@ -90,6 +92,7 @@ export function VideoPlayer(props: Props) {
|
||||
<>
|
||||
<KeyboardShortcutsAction />
|
||||
<PageTitleAction />
|
||||
<VolumeAdjustedAction />
|
||||
<VideoPlayerError onGoBack={props.onGoBack}>
|
||||
<BackdropAction onBackdropChange={onBackdropChange}>
|
||||
<CenterPosition>
|
||||
@@ -120,6 +123,7 @@ export function VideoPlayer(props: Props) {
|
||||
<HeaderAction
|
||||
showControls={isMobile}
|
||||
onClick={props.onGoBack}
|
||||
isFullScreen
|
||||
/>
|
||||
</Transition>
|
||||
<Transition
|
||||
@@ -141,9 +145,9 @@ export function VideoPlayer(props: Props) {
|
||||
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center">
|
||||
<div />
|
||||
<div className="flex items-center justify-center">
|
||||
<CaptionsSelectionAction />
|
||||
<SeriesSelectionAction />
|
||||
<SourceSelectionAction />
|
||||
<PictureInPictureAction />
|
||||
<SettingsAction />
|
||||
</div>
|
||||
<FullscreenAction />
|
||||
</div>
|
||||
@@ -151,13 +155,12 @@ export function VideoPlayer(props: Props) {
|
||||
<>
|
||||
<LeftSideControls />
|
||||
<div className="flex-1" />
|
||||
<QualityDisplayAction />
|
||||
<SeriesSelectionAction />
|
||||
<SourceSelectionAction />
|
||||
<div className="mx-2 h-6 w-px bg-white opacity-50" />
|
||||
<DividerAction />
|
||||
<SettingsAction />
|
||||
<ChromecastAction />
|
||||
<AirplayAction />
|
||||
<CaptionsSelectionAction />
|
||||
<PictureInPictureAction />
|
||||
<FullscreenAction />
|
||||
</>
|
||||
)}
|
||||
@@ -165,6 +168,7 @@ export function VideoPlayer(props: Props) {
|
||||
</Transition>
|
||||
{show ? <PopoutProviderAction /> : null}
|
||||
</BackdropAction>
|
||||
<CaptionRendererAction isControlsShown={show} />
|
||||
{props.children}
|
||||
</VideoPlayerError>
|
||||
</>
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
useVideoPlayerDescriptor,
|
||||
VideoPlayerContextProvider,
|
||||
} from "../state/hooks";
|
||||
import { MetaAction } from "./actions/MetaAction";
|
||||
import { VideoElementInternal } from "./internal/VideoElementInternal";
|
||||
|
||||
export interface VideoPlayerBaseProps {
|
||||
@@ -27,7 +28,9 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
|
||||
|
||||
const children =
|
||||
typeof props.children === "function"
|
||||
? props.children({ isFullscreen: videoInterface.isFullscreen })
|
||||
? props.children({
|
||||
isFullscreen: videoInterface.isFullscreen,
|
||||
})
|
||||
: props.children;
|
||||
|
||||
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
|
||||
@@ -36,12 +39,13 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
|
||||
<div
|
||||
ref={ref}
|
||||
className={[
|
||||
"is-video-player relative h-full w-full select-none overflow-hidden bg-black",
|
||||
"is-video-player popout-location relative h-full w-full select-none overflow-hidden bg-black",
|
||||
props.includeSafeArea || videoInterface.isFullscreen
|
||||
? "[border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
<MetaAction />
|
||||
<VideoElementInternal autoPlay={props.autoPlay} />
|
||||
<CastingInternal />
|
||||
<WrapperRegisterInternal wrapper={ref.current} />
|
||||
|
@@ -19,11 +19,21 @@ export function BackdropAction(props: BackdropActionProps) {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clickareaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const lastTouchEnd = useRef<number>(0);
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (!moved) setMoved(true);
|
||||
if (!moved) {
|
||||
setTimeout(() => {
|
||||
// If NOT a touch, set moved to true
|
||||
const isTouch = Date.now() - lastTouchEnd.current < 200;
|
||||
if (!isTouch) setMoved(true);
|
||||
}, 20);
|
||||
}
|
||||
|
||||
// remove after all
|
||||
if (timeout.current) clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => {
|
||||
if (moved) setMoved(false);
|
||||
setMoved(false);
|
||||
timeout.current = null;
|
||||
}, 3000);
|
||||
}, [setMoved, moved]);
|
||||
@@ -32,8 +42,6 @@ export function BackdropAction(props: BackdropActionProps) {
|
||||
setMoved(false);
|
||||
}, [setMoved]);
|
||||
|
||||
const [lastTouchEnd, setLastTouchEnd] = useState(0);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
||||
@@ -43,13 +51,17 @@ export function BackdropAction(props: BackdropActionProps) {
|
||||
if (videoInterface.popout !== null) return;
|
||||
|
||||
if ((e as React.TouchEvent).type === "touchend") {
|
||||
setLastTouchEnd(Date.now());
|
||||
lastTouchEnd.current = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e as React.MouseEvent<HTMLDivElement>).button !== 0) {
|
||||
return; // not main button (left click), exit event
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastTouchEnd < 200) {
|
||||
setMoved(!moved);
|
||||
if (Date.now() - lastTouchEnd.current < 200) {
|
||||
setMoved((v) => !v);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,7 +69,7 @@ export function BackdropAction(props: BackdropActionProps) {
|
||||
else controls.play();
|
||||
}, 20);
|
||||
},
|
||||
[controls, mediaPlaying, videoInterface, lastTouchEnd, moved]
|
||||
[controls, mediaPlaying, videoInterface]
|
||||
);
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
|
95
src/video/components/actions/CaptionRendererAction.tsx
Normal file
95
src/video/components/actions/CaptionRendererAction.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { sanitize, parseSubtitles } from "@/backend/helpers/captions";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
import { useRef } from "react";
|
||||
import { useAsync } from "react-use";
|
||||
import { useVideoPlayerDescriptor } from "../../state/hooks";
|
||||
import { useProgress } from "../../state/logic/progress";
|
||||
import { useSource } from "../../state/logic/source";
|
||||
|
||||
export function CaptionCue({ text, scale }: { text?: string; scale?: number }) {
|
||||
const { captionSettings } = useSettings();
|
||||
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
|
||||
|
||||
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
|
||||
// added a <br /> for newlines
|
||||
const html = sanitize(textWithNewlines, {
|
||||
ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"],
|
||||
ADD_TAGS: ["v", "lang"],
|
||||
ALLOWED_ATTR: ["title", "lang"],
|
||||
});
|
||||
|
||||
return (
|
||||
<p
|
||||
className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
|
||||
style={{
|
||||
...captionSettings.style,
|
||||
fontSize: captionSettings.style.fontSize * (scale ?? 1),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
// its sanitised a few lines up
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: html,
|
||||
}}
|
||||
dir="auto"
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CaptionRendererAction({
|
||||
isControlsShown,
|
||||
}: {
|
||||
isControlsShown: boolean;
|
||||
}) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const source = useSource(descriptor).source;
|
||||
const videoTime = useProgress(descriptor).time;
|
||||
const { captionSettings } = useSettings();
|
||||
const captions = useRef<ContentCaption[]>([]);
|
||||
|
||||
useAsync(async () => {
|
||||
const blobUrl = source?.caption?.url;
|
||||
if (blobUrl) {
|
||||
const result = await fetch(blobUrl);
|
||||
const text = await result.text();
|
||||
try {
|
||||
captions.current = parseSubtitles(text);
|
||||
} catch (error) {
|
||||
captions.current = [];
|
||||
}
|
||||
} else {
|
||||
captions.current = [];
|
||||
}
|
||||
}, [source?.caption?.url]);
|
||||
|
||||
if (!captions.current.length) return null;
|
||||
const isVisible = (start: number, end: number): boolean => {
|
||||
const delayedStart = start / 1000 + captionSettings.delay;
|
||||
const delayedEnd = end / 1000 + captionSettings.delay;
|
||||
return (
|
||||
Math.max(0, delayedStart) <= videoTime &&
|
||||
Math.max(0, delayedEnd) >= videoTime
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Transition
|
||||
className={[
|
||||
"pointer-events-none absolute flex w-full flex-col items-center transition-[bottom]",
|
||||
isControlsShown ? "bottom-24" : "bottom-12",
|
||||
].join(" ")}
|
||||
animation="slide-up"
|
||||
show
|
||||
>
|
||||
{captions.current.map(
|
||||
({ start, end, content }) =>
|
||||
isVisible(start, end) && (
|
||||
<CaptionCue key={`${start}-${end}`} text={content} />
|
||||
)
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
}
|
@@ -1,34 +0,0 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CaptionsSelectionAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="relative">
|
||||
<PopoutAnchor for="captions">
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
text={isMobile ? (t("videoPlayer.buttons.captions") as string) : ""}
|
||||
wide={isMobile}
|
||||
onClick={() => controls.openPopout("captions")}
|
||||
icon={Icons.CAPTIONS}
|
||||
/>
|
||||
</PopoutAnchor>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
12
src/video/components/actions/DividerAction.tsx
Normal file
12
src/video/components/actions/DividerAction.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
|
||||
export function DividerAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
|
||||
if (meta?.meta.meta.type !== MWMediaType.SERIES) return null;
|
||||
|
||||
return <div className="mx-2 h-6 w-px bg-white opacity-50" />;
|
||||
}
|
@@ -5,6 +5,7 @@ import { useMeta } from "@/video/state/logic/meta";
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
showControls?: boolean;
|
||||
isFullScreen: boolean;
|
||||
}
|
||||
|
||||
export function HeaderAction(props: Props) {
|
||||
|
@@ -60,7 +60,17 @@ export function KeyboardShortcutsAction() {
|
||||
|
||||
// Mute
|
||||
case "m":
|
||||
toggleVolume();
|
||||
toggleVolume(true);
|
||||
break;
|
||||
|
||||
// Decrease volume
|
||||
case "arrowdown":
|
||||
controls.setVolume(Math.max(mediaPlaying.volume - 0.1, 0), true);
|
||||
break;
|
||||
|
||||
// Increase volume
|
||||
case "arrowup":
|
||||
controls.setVolume(Math.min(mediaPlaying.volume + 0.1, 1), true);
|
||||
break;
|
||||
|
||||
// Do a barrel Roll!
|
||||
|
59
src/video/components/actions/MetaAction.tsx
Normal file
59
src/video/components/actions/MetaAction.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { MWCaption } from "@/backend/helpers/streams";
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { useProgress } from "@/video/state/logic/progress";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export type WindowMeta = {
|
||||
meta: DetailedMeta;
|
||||
captions: MWCaption[];
|
||||
episode?: {
|
||||
episodeId: string;
|
||||
seasonId: string;
|
||||
};
|
||||
seasons?: {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
episodes?: { id: string; number: number; title: string }[];
|
||||
}[];
|
||||
progress: {
|
||||
time: number;
|
||||
duration: number;
|
||||
};
|
||||
} | null;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
meta?: Record<string, WindowMeta>;
|
||||
}
|
||||
}
|
||||
|
||||
export function MetaAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
const progress = useProgress(descriptor);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.meta) window.meta = {};
|
||||
if (meta) {
|
||||
window.meta[descriptor] = {
|
||||
meta: meta.meta,
|
||||
captions: meta.captions,
|
||||
seasons: meta.seasons,
|
||||
episode: meta.episode,
|
||||
progress: {
|
||||
time: progress.time,
|
||||
duration: progress.duration,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (window.meta) delete window.meta[descriptor];
|
||||
};
|
||||
}, [meta, descriptor, progress]);
|
||||
|
||||
return null;
|
||||
}
|
40
src/video/components/actions/PictureInPictureAction.tsx
Normal file
40
src/video/components/actions/PictureInPictureAction.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
canPictureInPicture,
|
||||
canWebkitPictureInPicture,
|
||||
} from "@/utils/detectFeatures";
|
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PictureInPictureAction(props: Props) {
|
||||
const { isMobile } = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
controls.togglePictureInPicture();
|
||||
}, [controls]);
|
||||
|
||||
if (!canPictureInPicture() && !canWebkitPictureInPicture()) return null;
|
||||
|
||||
return (
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
icon={Icons.PICTURE_IN_PICTURE}
|
||||
onClick={handleClick}
|
||||
text={
|
||||
isMobile ? (t("videoPlayer.buttons.pictureInPicture") as string) : ""
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -4,9 +4,9 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -24,7 +24,7 @@ export function SeriesSelectionAction(props: Props) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="relative">
|
||||
<PopoutAnchor for="episodes">
|
||||
<FloatingAnchor id="episodes">
|
||||
<VideoPlayerIconButton
|
||||
active={videoInterface.popout === "episodes"}
|
||||
icon={Icons.EPISODES}
|
||||
@@ -32,7 +32,7 @@ export function SeriesSelectionAction(props: Props) {
|
||||
wide
|
||||
onClick={() => controls.openPopout("episodes")}
|
||||
/>
|
||||
</PopoutAnchor>
|
||||
</FloatingAnchor>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -2,33 +2,38 @@ import { Icons } from "@/components/Icon";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SourceSelectionAction(props: Props) {
|
||||
export function SettingsAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const controls = useControls(descriptor);
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const { isMobile } = useIsMobile(false);
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="relative">
|
||||
<PopoutAnchor for="source">
|
||||
<FloatingAnchor id="settings">
|
||||
<VideoPlayerIconButton
|
||||
active={videoInterface.popout === "source"}
|
||||
icon={Icons.CLAPPER_BOARD}
|
||||
iconSize="text-xl"
|
||||
text={t("videoPlayer.buttons.source") as string}
|
||||
wide
|
||||
onClick={() => controls.openPopout("source")}
|
||||
active={videoInterface.popout === "settings"}
|
||||
className={props.className}
|
||||
onClick={() => controls.openPopout("settings")}
|
||||
text={
|
||||
isMobile
|
||||
? (t("videoPlayer.buttons.settings") as string)
|
||||
: undefined
|
||||
}
|
||||
icon={Icons.GEAR}
|
||||
/>
|
||||
</PopoutAnchor>
|
||||
</FloatingAnchor>
|
||||
</div>
|
||||
</div>
|
||||
);
|
@@ -1,6 +1,11 @@
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useProgress } from "@/video/state/logic/progress";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { VideoPlayerTimeFormat } from "@/video/state/types";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
|
||||
function durationExceedsHour(secs: number): boolean {
|
||||
return secs > 60 * 60;
|
||||
@@ -37,19 +42,71 @@ export function TimeAction(props: Props) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoTime = useProgress(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
const { setTimeFormat } = useControls(descriptor);
|
||||
const { timeFormat } = useInterface(descriptor);
|
||||
const { isMobile } = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasHours = durationExceedsHour(videoTime.duration);
|
||||
const time = formatSeconds(
|
||||
|
||||
const currentTime = formatSeconds(
|
||||
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
|
||||
hasHours
|
||||
);
|
||||
const duration = formatSeconds(videoTime.duration, hasHours);
|
||||
const timeLeft = formatSeconds(
|
||||
(videoTime.duration - videoTime.time) / mediaPlaying.playbackSpeed,
|
||||
hasHours
|
||||
);
|
||||
const timeFinished = new Date(
|
||||
new Date().getTime() +
|
||||
(videoTime.duration * 1000) / mediaPlaying.playbackSpeed
|
||||
).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
const formattedTimeFinished = ` - ${t("videoPlayer.finishAt", {
|
||||
timeFinished,
|
||||
})}`;
|
||||
|
||||
let formattedTime: string;
|
||||
|
||||
if (timeFormat === VideoPlayerTimeFormat.REGULAR) {
|
||||
formattedTime = `${currentTime} ${props.noDuration ? "" : `/ ${duration}`}`;
|
||||
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && !isMobile) {
|
||||
formattedTime = `${t("videoPlayer.timeLeft", {
|
||||
timeLeft,
|
||||
})}${videoTime.time === videoTime.duration ? "" : formattedTimeFinished} `;
|
||||
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && isMobile) {
|
||||
formattedTime = `-${timeLeft}`;
|
||||
} else {
|
||||
formattedTime = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<p className="select-none text-white">
|
||||
{time} {props.noDuration ? "" : `/ ${duration}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"group pointer-events-auto text-white transition-transform duration-100 active:scale-110",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
setTimeFormat(
|
||||
timeFormat === VideoPlayerTimeFormat.REGULAR
|
||||
? VideoPlayerTimeFormat.REMAINING
|
||||
: VideoPlayerTimeFormat.REGULAR
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100 sm:px-4",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className={props.className}>
|
||||
<p className="select-none text-white">{formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
32
src/video/components/actions/VolumeAdjustedAction.tsx
Normal file
32
src/video/components/actions/VolumeAdjustedAction.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
|
||||
export function VolumeAdjustedAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
videoInterface.volumeChangedWithKeybind
|
||||
? "mt-10 scale-100 opacity-100"
|
||||
: "mt-5 scale-75 opacity-0",
|
||||
"absolute left-1/2 z-[100] flex -translate-x-1/2 items-center space-x-4 rounded-full bg-bink-300 bg-opacity-50 py-2 px-5 transition-all duration-100",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon
|
||||
icon={mediaPlaying.volume > 0 ? Icons.VOLUME : Icons.VOLUME_X}
|
||||
className="text-xl text-white"
|
||||
/>
|
||||
<div className="h-2 w-44 overflow-hidden rounded-full bg-denim-100">
|
||||
<div
|
||||
className="h-full rounded-r-full bg-bink-500 transition-[width] duration-100"
|
||||
style={{ width: `${mediaPlaying.volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -0,0 +1,17 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||
|
||||
interface Props {
|
||||
onClick: () => any;
|
||||
}
|
||||
|
||||
export function CaptionsSelectionAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PopoutListAction icon={Icons.CAPTIONS} onClick={props.onClick}>
|
||||
{t("videoPlayer.buttons.captions")}
|
||||
</PopoutListAction>
|
||||
);
|
||||
}
|
31
src/video/components/actions/list-entries/DownloadAction.tsx
Normal file
31
src/video/components/actions/list-entries/DownloadAction.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useSource } from "@/video/state/logic/source";
|
||||
import { MWStreamType } from "@/backend/helpers/streams";
|
||||
import { normalizeTitle } from "@/utils/normalizeTitle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||
|
||||
export function DownloadAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const sourceInterface = useSource(descriptor);
|
||||
const { t } = useTranslation();
|
||||
const meta = useMeta(descriptor);
|
||||
|
||||
const isHLS = sourceInterface.source?.type === MWStreamType.HLS;
|
||||
|
||||
if (isHLS) return null;
|
||||
|
||||
const title = meta?.meta.meta.title;
|
||||
|
||||
return (
|
||||
<PopoutListAction
|
||||
href={isHLS ? undefined : sourceInterface.source?.url}
|
||||
download={title ? `${normalizeTitle(title)}.mp4` : undefined}
|
||||
icon={Icons.DOWNLOAD}
|
||||
>
|
||||
{t("videoPlayer.buttons.download")}
|
||||
</PopoutListAction>
|
||||
);
|
||||
}
|
@@ -0,0 +1,17 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||
|
||||
interface Props {
|
||||
onClick: () => any;
|
||||
}
|
||||
|
||||
export function PlaybackSpeedSelectionAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PopoutListAction icon={Icons.TACHOMETER} onClick={props.onClick}>
|
||||
{t("videoPlayer.buttons.playbackSpeed")}
|
||||
</PopoutListAction>
|
||||
);
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||
import { QualityDisplayAction } from "./QualityDisplayAction";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => any;
|
||||
}
|
||||
|
||||
export function SourceSelectionAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PopoutListAction
|
||||
icon={Icons.CLAPPER_BOARD}
|
||||
onClick={props.onClick}
|
||||
right={<QualityDisplayAction />}
|
||||
noChevron
|
||||
>
|
||||
{t("videoPlayer.buttons.source")}
|
||||
</PopoutListAction>
|
||||
);
|
||||
}
|
@@ -1,4 +1,11 @@
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
import { getCaptionUrl, makeCaptionId } from "@/backend/helpers/captions";
|
||||
import {
|
||||
MWCaption,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { captionLanguages } from "@/setup/iso6391";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { useInitialized } from "@/video/components/hooks/useInitialized";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
@@ -8,6 +15,21 @@ interface SourceControllerProps {
|
||||
source: string;
|
||||
type: MWStreamType;
|
||||
quality: MWStreamQuality;
|
||||
providerId?: string;
|
||||
embedId?: string;
|
||||
captions: MWCaption[];
|
||||
}
|
||||
async function tryFetch(captions: MWCaption[]) {
|
||||
for (let i = 0; i < captions.length; i += 1) {
|
||||
const caption = captions[i];
|
||||
try {
|
||||
const blobUrl = await getCaptionUrl(caption);
|
||||
return { caption, blobUrl };
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SourceController(props: SourceControllerProps) {
|
||||
@@ -15,13 +37,35 @@ export function SourceController(props: SourceControllerProps) {
|
||||
const controls = useControls(descriptor);
|
||||
const { initialized } = useInitialized(descriptor);
|
||||
const didInitialize = useRef<boolean>(false);
|
||||
|
||||
const { captionSettings } = useSettings();
|
||||
useEffect(() => {
|
||||
if (didInitialize.current) return;
|
||||
if (!initialized) return;
|
||||
controls.setSource(props);
|
||||
// get preferred language
|
||||
const preferredLanguage = captionLanguages.find(
|
||||
(v) => v.id === captionSettings.language
|
||||
);
|
||||
if (!preferredLanguage) return;
|
||||
const captions = props.captions.filter(
|
||||
(v) =>
|
||||
// langIso may contain the English name or the native name of the language
|
||||
v.langIso.indexOf(preferredLanguage.englishName) !== -1 ||
|
||||
v.langIso.indexOf(preferredLanguage.nativeName) !== -1
|
||||
);
|
||||
if (!captions) return;
|
||||
// caption url can return a response other than 200
|
||||
// that's why we fetch until we get a 200 response
|
||||
tryFetch(captions).then((response) => {
|
||||
// none of them were successful
|
||||
if (!response) return;
|
||||
// set the preferred language
|
||||
const id = makeCaptionId(response.caption, true);
|
||||
controls.setCaption(id, response.blobUrl);
|
||||
});
|
||||
|
||||
didInitialize.current = true;
|
||||
}, [props, controls, initialized]);
|
||||
}, [props, controls, initialized, captionSettings.language]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useMisc } from "@/video/state/logic/misc";
|
||||
import { useSource } from "@/video/state/logic/source";
|
||||
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
|
||||
import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
@@ -13,7 +12,6 @@ interface Props {
|
||||
function VideoElement(props: Props) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
const source = useSource(descriptor);
|
||||
const misc = useMisc(descriptor);
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
@@ -43,12 +41,8 @@ function VideoElement(props: Props) {
|
||||
autoPlay={props.autoPlay}
|
||||
muted={mediaPlaying.volume === 0}
|
||||
playsInline
|
||||
className="h-full w-full"
|
||||
>
|
||||
{source.source?.caption ? (
|
||||
<track default kind="captions" src={source.source.caption.url} />
|
||||
) : null}
|
||||
</video>
|
||||
className="z-0 h-full w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -2,8 +2,9 @@ import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { conf } from "@/setup/config";
|
||||
import { Component, ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
|
||||
import { VideoPlayerHeader } from "./VideoPlayerHeader";
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user