mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 17:53:23 +00:00
Compare commits
378 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
3b0232b3d6 | ||
|
f2ea05708f | ||
|
772777835e | ||
|
dc58c2b55e | ||
|
c7f3f774bb | ||
|
96656d9a2f | ||
|
5419430369 | ||
|
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 | ||
|
f13ed7cae1 | ||
|
44f59e9708 | ||
|
92fa9716e5 | ||
|
e289f9a228 | ||
|
68868b37a8 | ||
|
b70b58602d | ||
|
62f8dc0e5e | ||
|
b83258a300 | ||
|
1d1dbf4bec | ||
|
9267b7bca1 | ||
|
0f735f49d9 | ||
|
cca38680fe | ||
|
a8c84f7343 | ||
|
68a186963c | ||
|
a2e647297a | ||
|
398644951e | ||
|
b886443ea7 | ||
|
d6d318006b | ||
|
0c57aa1a73 | ||
|
aaf0b56ee7 | ||
|
b3db58012f | ||
|
c90d59ef93 | ||
|
c441d63074 | ||
|
a0751380e5 | ||
|
209fe4369c | ||
|
4a35287975 | ||
|
b43f39b007 | ||
|
4f682d55a9 | ||
|
ad518a6508 | ||
|
4d4626806d | ||
|
18b7619328 | ||
|
75762aca48 | ||
|
eaf5730415 | ||
|
224de76578 | ||
|
df5f1a5fdb | ||
|
f46263385b | ||
|
bf3bca9b53 | ||
|
a93569a201 | ||
|
4a0392d1f0 | ||
|
424ec25c5a | ||
|
bd48d929b9 | ||
|
e569f15661 | ||
|
dcc158e705 | ||
|
942a6cc9c0 | ||
|
dd14b575eb | ||
|
8f23240ea1 | ||
|
886ffe78ef | ||
|
772be4b42d | ||
|
e448c0b5a8 | ||
|
056f837dcb | ||
|
d89bbaef97 | ||
|
0193e8f0c8 | ||
|
6d24e8aa81 | ||
|
f14606e579 | ||
|
f97b84516b | ||
|
c4712044a9 | ||
|
d9ccce1726 | ||
|
bd7799b5c1 | ||
|
d8e2597db7 | ||
|
f8b5c4169c | ||
|
0105c4f6b2 | ||
|
403142783c | ||
|
2a3c93c24f | ||
|
3b4e9ce2ca | ||
|
6224fb32c4 | ||
|
5e433266ee | ||
|
5d5a727663 | ||
|
76e4bc5851 | ||
|
487ba39bbf | ||
|
d213daf91e | ||
|
210e60c24d | ||
|
63be27b9ae | ||
|
c3b409631e | ||
|
bb14d63a9c | ||
|
27ef9be6b1 | ||
|
a0c24209bb | ||
|
c5a8065db9 | ||
|
6ca3196b75 | ||
|
4d40339602 | ||
|
6e67038ae7 | ||
|
73e6f26adb | ||
|
a1cae1c9f7 | ||
|
b1333cfc16 | ||
|
52fef27374 | ||
|
2b81d061f4 | ||
|
6edc0d3959 | ||
|
8c9d905a91 | ||
|
701b3db798 | ||
|
0ca751f1d2 | ||
|
c3985873d4 | ||
|
3604a2f0d7 | ||
|
6d9a963592 | ||
|
22a2ebac74 | ||
|
dfbaac8e93 | ||
|
da097b97d1 | ||
|
6de43d29b9 | ||
|
177860aed4 | ||
|
a077417761 | ||
|
20685577ab | ||
|
b2748f7390 | ||
|
b8e49850f4 | ||
|
1f7e8abda5 | ||
|
b6ff4bf800 | ||
|
62220532d7 | ||
|
1579e23dba | ||
|
9e8769e4c3 | ||
|
f339a7156a | ||
|
fa9785bf69 | ||
|
ec6e145f82 | ||
|
5e1727e8f7 | ||
|
1185383ae4 | ||
|
7a2865313d | ||
|
e7a6484094 | ||
|
489f536722 | ||
|
f472f04735 | ||
|
5a01a68ce4 | ||
|
b6a23aa0b7 | ||
|
02cc4b7f1d | ||
|
9cb182d201 | ||
|
5ca384a0f7 | ||
|
fb96026195 | ||
|
6353bf3799 | ||
|
40cca10660 | ||
|
4d2fc166bc | ||
|
f37bec7a7a | ||
|
f656f80996 | ||
|
714b378f68 | ||
|
a369682a26 | ||
|
ca169769bb | ||
|
52b063b10a | ||
|
8e522e18d4 | ||
|
d161c948cd | ||
|
2f1058cb9c | ||
|
cf83df64bb | ||
|
5967c83d28 | ||
|
4d07751a4a | ||
|
a64841507f | ||
|
6589e095ec | ||
|
a9ac3e64db | ||
|
094f9208a8 | ||
|
e34ddddddb | ||
|
f1257973e7 | ||
|
8268abc45d | ||
|
46e933dfb7 | ||
|
d28e6e6735 | ||
|
35c7ac4b8d | ||
|
02ef6c5bf1 | ||
|
2d9b66d9b8 | ||
|
351b35ef98 | ||
|
024325f640 | ||
|
098f6af0ae | ||
|
b43b8b19e4 | ||
|
44149203cb | ||
|
a9cf056276 | ||
|
09634c6f97 | ||
|
61abce9386 | ||
|
218a14d5f6 | ||
|
f93b9b5b0f | ||
|
196d6ae6e5 | ||
|
3a67d50f42 | ||
|
eeaa4d7571 | ||
|
b98fdcd94d | ||
|
9fba422673 | ||
|
e7981539e6 | ||
|
42402eb5c7 | ||
|
9d865ca7b4 | ||
|
4dd0f22a04 | ||
|
a9c34d6e35 | ||
|
844f5d8b3f | ||
|
a2e27b1967 | ||
|
06256e311d | ||
|
afd2875715 | ||
|
9851936c69 | ||
|
aab58815e0 | ||
|
77678063b4 | ||
|
2f713d3394 | ||
|
63cc59d518 | ||
|
9a16aff7aa | ||
|
36821ff140 | ||
|
3b7a95ff62 | ||
|
1967c47e31 | ||
|
98ebc9aec8 | ||
|
80799b7600 | ||
|
93cb97b304 | ||
|
131706e2bb | ||
|
ffcba436d7 | ||
|
d73ee207da | ||
|
c23c1feebc | ||
|
388827b56f | ||
|
2e8025a241 | ||
|
d6edb16ab1 | ||
|
4731f350d9 | ||
|
02e912a760 | ||
|
721b8022ab | ||
|
c3e77383ea |
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
41
.eslintrc.js
41
.eslintrc.js
@@ -1,27 +1,34 @@
|
|||||||
const a11yOff = Object.keys(require('eslint-plugin-jsx-a11y').rules)
|
const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
|
||||||
.reduce((acc, rule) => { acc[`jsx-a11y/${rule}`] = 'off'; return acc }, {})
|
(acc, rule) => {
|
||||||
|
acc[`jsx-a11y/${rule}`] = "off";
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
browser: true
|
||||||
|
},
|
||||||
extends: [
|
extends: [
|
||||||
"airbnb",
|
"airbnb",
|
||||||
"airbnb/hooks",
|
"airbnb/hooks",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"prettier",
|
"prettier",
|
||||||
|
"plugin:prettier/recommended"
|
||||||
],
|
],
|
||||||
settings: {
|
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
|
||||||
"import/resolver": {
|
|
||||||
typescript: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: "./tsconfig.json",
|
project: "./tsconfig.json",
|
||||||
tsconfigRootDir: "./",
|
tsconfigRootDir: "./"
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
"import/resolver": {
|
||||||
|
typescript: {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: ["@typescript-eslint", "import"],
|
plugins: ["@typescript-eslint", "import"],
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
},
|
|
||||||
rules: {
|
rules: {
|
||||||
"react/jsx-uses-react": "off",
|
"react/jsx-uses-react": "off",
|
||||||
"react/react-in-jsx-scope": "off",
|
"react/react-in-jsx-scope": "off",
|
||||||
@@ -36,23 +43,27 @@ module.exports = {
|
|||||||
"no-shadow": "off",
|
"no-shadow": "off",
|
||||||
"@typescript-eslint/no-shadow": ["error"],
|
"@typescript-eslint/no-shadow": ["error"],
|
||||||
"no-restricted-syntax": "off",
|
"no-restricted-syntax": "off",
|
||||||
|
"import/no-unresolved": ["error", { ignore: ["^virtual:"] }],
|
||||||
"react/jsx-props-no-spreading": "off",
|
"react/jsx-props-no-spreading": "off",
|
||||||
"consistent-return": "off",
|
"consistent-return": "off",
|
||||||
"no-continue": "off",
|
"no-continue": "off",
|
||||||
"no-eval": "off",
|
"no-eval": "off",
|
||||||
"no-await-in-loop": "off",
|
"no-await-in-loop": "off",
|
||||||
|
"no-nested-ternary": "off",
|
||||||
|
"prefer-destructuring": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||||
"react/jsx-filename-extension": [
|
"react/jsx-filename-extension": [
|
||||||
"error",
|
"error",
|
||||||
{ extensions: [".js", ".tsx", ".jsx"] },
|
{ extensions: [".js", ".tsx", ".jsx"] }
|
||||||
],
|
],
|
||||||
"import/extensions": [
|
"import/extensions": [
|
||||||
"error",
|
"error",
|
||||||
"ignorePackages",
|
"ignorePackages",
|
||||||
{
|
{
|
||||||
ts: "never",
|
ts: "never",
|
||||||
tsx: "never",
|
tsx: "never"
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
...a11yOff
|
...a11yOff
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
38
.github/workflows/deploying.yml
vendored
38
.github/workflows/deploying.yml
vendored
@@ -12,44 +12,26 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 18
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
- name: Install Yarn packages
|
- name: Install Yarn packages
|
||||||
run: yarn install
|
run: yarn install
|
||||||
|
|
||||||
- name: Build project
|
- name: Build project
|
||||||
run: npm run build
|
run: yarn build
|
||||||
|
|
||||||
- name: Upload production-ready build files
|
- name: Upload production-ready build files
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: production-files
|
name: production-files
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download artifact
|
|
||||||
uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: production-files
|
|
||||||
path: ./dist
|
|
||||||
|
|
||||||
- name: Deploy to gh-pages
|
|
||||||
uses: peaceiris/actions-gh-pages@v3
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
publish_dir: ./dist
|
|
||||||
cname: movie.squeezebox.dev
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Release
|
name: Release
|
||||||
needs: build
|
needs: build
|
||||||
@@ -57,16 +39,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: production-files
|
name: production-files
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
|
||||||
- name: Zip files
|
- name: Zip files
|
||||||
run: zip -r ./movie-web.zip ./dist
|
run: cd dist && zip -r ../movie-web.zip .
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: package-version
|
id: package-version
|
||||||
@@ -91,5 +73,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
asset_path: ./movie-web.zip
|
asset_path: ./movie-web.zip
|
||||||
asset_name: movie-web.js
|
asset_name: movie-web.zip
|
||||||
asset_content_type: application/zip
|
asset_content_type: application/zip
|
||||||
|
29
.github/workflows/linting.yml
vendored
29
.github/workflows/linting.yml
vendored
@@ -1,29 +0,0 @@
|
|||||||
name: Linting
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- dev
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened, synchronize]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
linting:
|
|
||||||
name: Run linters
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Install Node.js
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 16
|
|
||||||
|
|
||||||
- name: Install Yarn packages
|
|
||||||
run: yarn install
|
|
||||||
|
|
||||||
- name: Run linters
|
|
||||||
run: yarn lint:strict
|
|
49
.github/workflows/linting_testing.yml
vendored
Normal file
49
.github/workflows/linting_testing.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Linting and Testing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linting:
|
||||||
|
name: Run Linters
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install Yarn packages
|
||||||
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: yarn lint
|
||||||
|
|
||||||
|
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:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install Yarn packages
|
||||||
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Build Project
|
||||||
|
run: yarn build
|
30
.github/workflows/testing.yml
vendored
30
.github/workflows/testing.yml
vendored
@@ -1,30 +0,0 @@
|
|||||||
name: Testing
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- dev
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened, synchronize]
|
|
||||||
|
|
||||||
permissions: read-all
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Install Node.js
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 16
|
|
||||||
|
|
||||||
- name: Install Yarn packages
|
|
||||||
run: yarn install
|
|
||||||
|
|
||||||
- name: Build project
|
|
||||||
run: yarn build
|
|
6
.gitignore
vendored
6
.gitignore
vendored
@@ -9,7 +9,8 @@ node_modules
|
|||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/dist
|
||||||
|
dev-dist
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -23,3 +24,6 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
# config
|
||||||
|
.env
|
||||||
|
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"editorconfig.editorconfig"
|
||||||
|
]
|
||||||
|
}
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"files.eol": "\n",
|
|
||||||
"editor.detectIndentation": false,
|
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.tabSize": 2
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||||
|
"eslint.format.enable": true,
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
}
|
}
|
40
README.md
40
README.md
@@ -1,16 +1,16 @@
|
|||||||
<h1>movie-web</h1>
|
<h1>movie-web</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/JamesHawkinss/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/JamesHawkinss/movie-web/deploying.yml?branch=master&style=flat-square"></a>
|
<a href="https://github.com/movie-web/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/movie-web/movie-web/deploying.yml?branch=master&style=flat-square"></a>
|
||||||
<a href="https://github.com/JamesHawkinss/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/JamesHawkinss/movie-web?style=flat-square"></a>
|
<a href="https://github.com/movie-web/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/movie-web/movie-web?style=flat-square"></a>
|
||||||
<a href="https://github.com/JamesHawkinss/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/JamesHawkinss/movie-web?style=flat-square"></a>
|
<a href="https://github.com/movie-web/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/movie-web/movie-web?style=flat-square"></a>
|
||||||
<a href="https://github.com/JamesHawkinss/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/JamesHawkinss/movie-web?style=flat-square"></a><br/>
|
<a href="https://github.com/movie-web/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/movie-web/movie-web?style=flat-square"></a><br/>
|
||||||
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||||
</p>
|
</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.squeezebox.dev](https://movie.squeezebox.dev)**.
|
||||||
|
|
||||||
This service works by displaying video files from third-party providers inside an intuitive and aesthic user interface.
|
This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.
|
||||||
|
|
||||||
Features include:
|
Features include:
|
||||||
|
|
||||||
@@ -25,26 +25,32 @@ Features include:
|
|||||||
- No BS: just a search bar and a video player
|
- No BS: just a search bar and a video player
|
||||||
- No responsibility on the hoster, no databases or api's hosted by us, just a static site
|
- No responsibility on the hoster, no databases or api's hosted by us, just a static site
|
||||||
|
|
||||||
## Self-hosting / running locally
|
## Self-hosting
|
||||||
|
|
||||||
|
A simple guide has been written to assist in hosting your own instance of movie-web.
|
||||||
|
|
||||||
|
Check it out here: [https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md](https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md)
|
||||||
|
|
||||||
|
## Running locally for development
|
||||||
|
|
||||||
To run this project locally for contributing or testing, run the following commands:
|
To run this project locally for contributing or testing, run the following commands:
|
||||||
<h5><b>note: must use yarn to install packages and run NodeJS 16</b></h5>
|
<h5><b>note: must use yarn to install packages and run NodeJS 16</b></h5>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/JamesHawkinss/movie-web
|
git clone https://github.com/movie-web/movie-web
|
||||||
cd movie-web
|
cd movie-web
|
||||||
yarn install
|
yarn install
|
||||||
yarn start
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
To build production files, simply run `yarn build`.
|
To build production files, simply run `yarn build`.
|
||||||
|
|
||||||
You can also deploy the Cloudflare Worker (in worker.js) and update the proxy URL constant in `/src/mw-constants.ts`.
|
You'll need to deploy a cloudflare service worker as well. Check the [selfhosting guide](https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md) on how to run the service worker. Afterwards you can make a `.env` file and put in the URL. (see `example.env` for an example)
|
||||||
|
|
||||||
<h2>Contributing - <a href="https://github.com/JamesHawkinss/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/JamesHawkinss/movie-web?style=flat-square"></a>
|
<h2>Contributing - <a href="https://github.com/movie-web/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/movie-web/movie-web?style=flat-square"></a>
|
||||||
<a href="https://github.com/JamesHawkinss/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/JamesHawkinss/movie-web?style=flat-square"></a></h2>
|
<a href="https://github.com/movie-web/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/movie-web/movie-web?style=flat-square"></a></h2>
|
||||||
|
|
||||||
Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
|
Check out [this project's issues](https://github.com/movie-web/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
|
||||||
|
|
||||||
**All pull requests must be merged into the `dev` branch. it will then be deployed with the next version**
|
**All pull requests must be merged into the `dev` branch. it will then be deployed with the next version**
|
||||||
|
|
||||||
@@ -52,7 +58,11 @@ Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/iss
|
|||||||
|
|
||||||
This project would not be possible without our amazing contributors and the community.
|
This project would not be possible without our amazing contributors and the community.
|
||||||
|
|
||||||
<a href="https://github.com/JamesHawkinss/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/JamesHawkinss/movie-web?style=flat-square"></a>
|
<a href="https://github.com/movie-web/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/movie-web/movie-web?style=flat-square"></a>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||||
|
<img src="https://github.com/JamesHawkinss.png?size=20" width="20"><span><a href="https://github.com/JamesHawkinss">@JamesHawkinss</a> for original concept.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||||
<img src="https://github.com/JipFr.png?size=20" width="20"><span><a href="https://github.com/JipFr">@JipFr</a> for initial work on <a href="https://github.com/JipFr/movie-cli">movie-cli</a>.</span>
|
<img src="https://github.com/JipFr.png?size=20" width="20"><span><a href="https://github.com/JipFr">@JipFr</a> for initial work on <a href="https://github.com/JipFr/movie-cli">movie-cli</a>.</span>
|
||||||
@@ -62,10 +72,6 @@ This project would not be possible without our amazing contributors and the comm
|
|||||||
<img src="https://github.com/mrjvs.png?size=20" width="20"><span><a href="https://github.com/mrjvs">@mrjvs</a> for leading the port to React, and for the beautiful design.</span>
|
<img src="https://github.com/mrjvs.png?size=20" width="20"><span><a href="https://github.com/mrjvs">@mrjvs</a> for leading the port to React, and for the beautiful design.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
|
||||||
<img src="https://github.com/JoshHeng.png?size=20" width="20"><span><a href="https://github.com/JoshHeng">@JoshHeng</a> for the Cloudflare CORS Proxy and URL routing.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||||
<img src="https://github.com/binaryoverload.png?size=20" width="20"><span><a href="https://github.com/binaryoverload">@binaryoverload</a> for help rewriting the application into React and making the README look ✨ pretty ✨.</span>
|
<img src="https://github.com/binaryoverload.png?size=20" width="20"><span><a href="https://github.com/binaryoverload">@binaryoverload</a> for help rewriting the application into React and making the README look ✨ pretty ✨.</span>
|
||||||
</div>
|
</div>
|
||||||
|
38
SELFHOSTING.md
Normal file
38
SELFHOSTING.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Selfhosting tutorial
|
||||||
|
|
||||||
|
> **Note:** We do not provide support on how to selfhost, if you cant figure it out then tough luck. Please do not make Github issues or ask in our Discord server for support on how to selfhost.
|
||||||
|
|
||||||
|
So you wanna selfhost. This app is made of two parts:
|
||||||
|
- The proxy
|
||||||
|
- The client
|
||||||
|
|
||||||
|
## Hosting the proxy
|
||||||
|
|
||||||
|
The proxy is made as a cloudflare worker, cloudflare has a generous free plan, so you don't need to pay anything unless you get hundreds of users.
|
||||||
|
|
||||||
|
1. Create a cloudflare account at [https://dash.cloudflare.com](https://dash.cloudflare.com)
|
||||||
|
2. Navigate to `Workers`.
|
||||||
|
3. If it asks you, choose a subdomain
|
||||||
|
4. If it asks for a workers plan, press "Continue with free"
|
||||||
|
5. Create a new service with a name of your choice. Must be type `HTTP handler`
|
||||||
|
6. On the service page, Click `Quick edit`
|
||||||
|
7. Download the `worker.js` file from the latest release of the proxy: [https://github.com/movie-web/simple-proxy/releases/latest](https://github.com/movie-web/simple-proxy/releases/latest)
|
||||||
|
8. Open the downloaded `worker.js` file in notepad, VScode or similar.
|
||||||
|
9. Copy the text contents of the `worker.js` file.
|
||||||
|
10. Paste the text contents into the edit screen of the cloudflare service worker.
|
||||||
|
11. Click `Save and deploy` and confirm.
|
||||||
|
|
||||||
|
Your proxy is now hosted on cloudflare. Note the url of your worker. you will need it later.
|
||||||
|
|
||||||
|
## Hosting the client
|
||||||
|
|
||||||
|
1. Download the file `movie-web.zip` from the latest release: [https://github.com/movie-web/movie-web/releases/latest](https://github.com/movie-web/movie-web/releases/latest)
|
||||||
|
2. Extract the zip file so you can edit the files.
|
||||||
|
3. Open `config.js` in notepad, VScode or similar.
|
||||||
|
4. Put your cloudflare proxy URL inbetween the double qoutes of `VITE_CORS_PROXY_URL: "",`. Make sure to not have a slash at the end of your URL.
|
||||||
|
|
||||||
|
Example (THIS IS MINE, IT WONT WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev",`
|
||||||
|
5. Save the file
|
||||||
|
|
||||||
|
Your client has been prepared, you can now host it on any webhost.
|
||||||
|
It doesn't require php, its just a standard static page.
|
6
example.env
Normal file
6
example.env
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# make sure the cors proxy url does NOT have a slash at the end
|
||||||
|
VITE_CORS_PROXY_URL=...
|
||||||
|
|
||||||
|
# the keys below are optional - defaults are provided
|
||||||
|
VITE_TMDB_API_KEY=...
|
||||||
|
VITE_OMDB_API_KEY=...
|
34
index.html
34
index.html
@@ -1,44 +1,34 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
|
||||||
<script
|
|
||||||
async
|
|
||||||
src="https://www.googletagmanager.com/gtag/js?id=G-44YVXRL61C"
|
|
||||||
></script>
|
|
||||||
<script>
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag() {
|
|
||||||
dataLayer.push(arguments);
|
|
||||||
}
|
|
||||||
gtag("js", new Date());
|
|
||||||
|
|
||||||
gtag("config", "G-44YVXRL61C");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
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="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="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.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="#120f1d" />
|
||||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#E880C5" />
|
<meta name="msapplication-TileColor" content="#120f1d" />
|
||||||
<meta name="msapplication-TileColor" content="#E880C5" />
|
<meta name="theme-color" content="#120f1d" />
|
||||||
<meta name="theme-color" content="#E880C5" />
|
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<script src="config.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@8b821f445b83d51ef1b8f42c99b7346f6b47dce5/out.js"></script>
|
||||||
|
|
||||||
|
<!-- prevent darkreader extension from messing with our already dark site -->
|
||||||
|
<meta name="darkreader-lock" />
|
||||||
|
|
||||||
<title>movie-web</title>
|
<title>movie-web</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
55
package.json
55
package.json
@@ -1,37 +1,52 @@
|
|||||||
{
|
{
|
||||||
"name": "movie-web",
|
"name": "movie-web",
|
||||||
"version": "2.1.0",
|
"version": "3.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://movie.squeezebox.dev",
|
"homepage": "https://movie.squeezebox.dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||||
"@headlessui/react": "^1.5.0",
|
"@headlessui/react": "^1.5.0",
|
||||||
|
"@react-spring/web": "^9.7.1",
|
||||||
|
"@use-gesture/react": "^10.2.24",
|
||||||
|
"core-js": "^3.29.1",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
|
"dompurify": "^3.0.1",
|
||||||
|
"fscreen": "^1.2.0",
|
||||||
"fuse.js": "^6.4.6",
|
"fuse.js": "^6.4.6",
|
||||||
"hls.js": "^1.0.7",
|
"hls.js": "^1.0.7",
|
||||||
"i18next": "^22.4.5",
|
"i18next": "^22.4.5",
|
||||||
"i18next-browser-languagedetector": "^7.0.1",
|
"i18next-browser-languagedetector": "^7.0.1",
|
||||||
"i18next-http-backend": "^2.1.0",
|
|
||||||
"json5": "^2.2.0",
|
"json5": "^2.2.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
"nanoid": "^4.0.0",
|
"nanoid": "^4.0.0",
|
||||||
|
"node-webvtt": "^1.9.4",
|
||||||
|
"ofetch": "^1.0.0",
|
||||||
|
"pako": "^2.1.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
"react-ga4": "^2.0.0",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
"react-i18next": "^12.1.1",
|
"react-i18next": "^12.1.1",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
|
"react-stickynode": "^4.1.0",
|
||||||
|
"react-transition-group": "^4.4.5",
|
||||||
|
"react-use": "^17.4.0",
|
||||||
"srt-webvtt": "^2.0.0",
|
"srt-webvtt": "^2.0.0",
|
||||||
"unpacker": "^1.0.1"
|
"unpacker": "^1.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"test": "vitest run",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint --ext .tsx,.ts src",
|
"lint": "eslint --ext .tsx,.ts src",
|
||||||
"lint:strict": "eslint --ext .tsx,.ts --max-warnings 0 src"
|
"lint:fix": "eslint --fix --ext .tsx,.ts src",
|
||||||
|
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
">0.2%",
|
"defaults",
|
||||||
"not dead",
|
"chrome > 90"
|
||||||
"not op_mini all"
|
|
||||||
],
|
],
|
||||||
"development": [
|
"development": [
|
||||||
"last 1 chrome version",
|
"last 1 chrome version",
|
||||||
@@ -40,30 +55,50 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/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/node": "^17.0.15",
|
||||||
|
"@types/pako": "^2.0.0",
|
||||||
"@types/react": "^17.0.39",
|
"@types/react": "^17.0.39",
|
||||||
"@types/react-dom": "^17.0.11",
|
"@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-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/eslint-plugin": "^5.13.0",
|
||||||
"@typescript-eslint/parser": "^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",
|
"autoprefixer": "^10.4.13",
|
||||||
"eslint": "^8.10.0",
|
"eslint": "^8.10.0",
|
||||||
"eslint-config-airbnb": "19.0.4",
|
"eslint-config-airbnb": "19.0.4",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-import-resolver-typescript": "^2.5.0",
|
"eslint-import-resolver-typescript": "^2.5.0",
|
||||||
"eslint-plugin-import": "^2.25.4",
|
"eslint-plugin-import": "^2.25.4",
|
||||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||||
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "7.29.4",
|
"eslint-plugin-react": "7.29.4",
|
||||||
"eslint-plugin-react-hooks": "4.3.0",
|
"eslint-plugin-react-hooks": "4.3.0",
|
||||||
|
"jsdom": "^21.1.0",
|
||||||
"postcss": "^8.4.20",
|
"postcss": "^8.4.20",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||||
"tailwind-scrollbar": "^2.0.1",
|
"tailwind-scrollbar": "^2.0.1",
|
||||||
"tailwindcss": "^3.2.4",
|
"tailwindcss": "^3.2.4",
|
||||||
"typescript": "^4.6.4",
|
"typescript": "^4.6.4",
|
||||||
"vite": "^4.0.1"
|
"vite": "^4.0.1",
|
||||||
|
"vite-plugin-checker": "^0.5.6",
|
||||||
|
"vite-plugin-package-version": "^1.0.2",
|
||||||
|
"vite-plugin-pwa": "^0.14.4",
|
||||||
|
"vitest": "^0.28.5",
|
||||||
|
"workbox-build": "^6.5.4",
|
||||||
|
"workbox-window": "^6.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4
prettierrc.js
Normal file
4
prettierrc.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
trailingComma: "all",
|
||||||
|
singleQuote: true
|
||||||
|
};
|
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
|
1
public/_redirects
Normal file
1
public/_redirects
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* /index.html 200
|
@@ -3,7 +3,7 @@
|
|||||||
<msapplication>
|
<msapplication>
|
||||||
<tile>
|
<tile>
|
||||||
<square150x150logo src="/mstile-150x150.png"/>
|
<square150x150logo src="/mstile-150x150.png"/>
|
||||||
<TileColor>#da532c</TileColor>
|
<TileColor>#120f1d</TileColor>
|
||||||
</tile>
|
</tile>
|
||||||
</msapplication>
|
</msapplication>
|
||||||
</browserconfig>
|
</browserconfig>
|
||||||
|
6
public/config.js
Normal file
6
public/config.js
Normal file
@@ -0,0 +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",
|
||||||
|
};
|
@@ -1,50 +0,0 @@
|
|||||||
{
|
|
||||||
"global": {
|
|
||||||
"name": "movie-web"
|
|
||||||
},
|
|
||||||
"search": {
|
|
||||||
"loading": "Fetching your favourite shows...",
|
|
||||||
"providersFailed": "{{fails}}/{{total}} providers failed!",
|
|
||||||
"allResults": "That's all we have!",
|
|
||||||
"noResults": "We couldn't find anything!",
|
|
||||||
"allFailed": "All providers have failed!",
|
|
||||||
"headingTitle": "Search results",
|
|
||||||
"headingLink": "Back to home",
|
|
||||||
"bookmarks": "Bookmarks",
|
|
||||||
"continueWatching": "Continue Watching",
|
|
||||||
"tagline": "Because watching legally is boring",
|
|
||||||
"title": "What do you want to watch?",
|
|
||||||
"placeholder": "What do you want to watch?"
|
|
||||||
},
|
|
||||||
"media": {
|
|
||||||
"invalidUrl": "Your URL may be invalid",
|
|
||||||
"arrowText": "Go back"
|
|
||||||
},
|
|
||||||
"seasons": {
|
|
||||||
"season": "Season {{season}}",
|
|
||||||
"failed": "Failed to get season data"
|
|
||||||
},
|
|
||||||
"notFound": {
|
|
||||||
"backArrow": "Back to home",
|
|
||||||
"media": {
|
|
||||||
"title": "Couldn't find that media",
|
|
||||||
"description": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL"
|
|
||||||
},
|
|
||||||
"provider": {
|
|
||||||
"title": "This provider has been disabled",
|
|
||||||
"description": "We had issues with the provider or it was too unstable to use, so we had to disable it."
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"title": "Couldn't find that page",
|
|
||||||
"description": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"searchBar": {
|
|
||||||
"movie": "Movie",
|
|
||||||
"series": "Series",
|
|
||||||
"Search": "Search"
|
|
||||||
},
|
|
||||||
"errorBoundary": {
|
|
||||||
"text": "The app encountered an error and wasn't able to recover, please report it to the"
|
|
||||||
}
|
|
||||||
}
|
|
1
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": "/"
|
|
||||||
}
|
|
27
src/@types/node_webtt.d.ts
vendored
Normal file
27
src/@types/node_webtt.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
declare module "node-webvtt" {
|
||||||
|
interface Cue {
|
||||||
|
identifier: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
styles: string;
|
||||||
|
}
|
||||||
|
interface Options {
|
||||||
|
meta?: boolean;
|
||||||
|
strict?: boolean;
|
||||||
|
}
|
||||||
|
type ParserError = Error;
|
||||||
|
interface ParseResult {
|
||||||
|
valid: boolean;
|
||||||
|
strict: boolean;
|
||||||
|
cues: Cue[];
|
||||||
|
errors: ParserError[];
|
||||||
|
meta?: Map<string, string>;
|
||||||
|
}
|
||||||
|
interface Segment {
|
||||||
|
duration: number;
|
||||||
|
cues: Cue[];
|
||||||
|
}
|
||||||
|
function parse(text: string, options: Options): ParseResult;
|
||||||
|
function segment(input: string, segmentLength?: number): Segment[];
|
||||||
|
}
|
28
src/App.tsx
28
src/App.tsx
@@ -1,28 +0,0 @@
|
|||||||
import { Redirect, Route, Switch } from "react-router-dom";
|
|
||||||
import { MWMediaType } from "@/providers";
|
|
||||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
|
||||||
import { WatchedContextProvider } from "@/state/watched";
|
|
||||||
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
|
||||||
import "./index.css";
|
|
||||||
import { MediaView } from "./views/MediaView";
|
|
||||||
import { SearchView } from "./views/SearchView";
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<WatchedContextProvider>
|
|
||||||
<BookmarkContextProvider>
|
|
||||||
<Switch>
|
|
||||||
<Route exact path="/">
|
|
||||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
|
||||||
</Route>
|
|
||||||
<Route exact path="/media/movie/:media" component={MediaView} />
|
|
||||||
<Route exact path="/media/series/:media" component={MediaView} />
|
|
||||||
<Route exact path="/search/:type/:query?" component={SearchView} />
|
|
||||||
<Route path="*" component={NotFoundPage} />
|
|
||||||
</Switch>
|
|
||||||
</BookmarkContextProvider>
|
|
||||||
</WatchedContextProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
51
src/__tests__/providers/providers.test.ts
Normal file
51
src/__tests__/providers/providers.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
import "@/backend";
|
||||||
|
import { getProviders } from "@/backend/helpers/register";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
import { runProvider } from "@/backend/helpers/run";
|
||||||
|
import { testData } from "@/__tests__/providers/testdata";
|
||||||
|
|
||||||
|
describe("providers", () => {
|
||||||
|
const providers = getProviders();
|
||||||
|
|
||||||
|
it("have at least one provider", ({ expect }) => {
|
||||||
|
expect(providers.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
describe(provider.displayName, () => {
|
||||||
|
it("must have at least one type", async ({ expect }) => {
|
||||||
|
expect(provider.type.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (provider.type.includes(MWMediaType.MOVIE)) {
|
||||||
|
it("must work with movies", async ({ expect }) => {
|
||||||
|
const movie = testData.find((v) => v.meta.type === MWMediaType.MOVIE);
|
||||||
|
if (!movie) throw new Error("no movie to test with");
|
||||||
|
const results = await runProvider(provider, {
|
||||||
|
media: movie,
|
||||||
|
progress() {},
|
||||||
|
type: movie.meta.type as any,
|
||||||
|
});
|
||||||
|
expect(results).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.type.includes(MWMediaType.SERIES)) {
|
||||||
|
it("must work with series", async ({ expect }) => {
|
||||||
|
const show = testData.find((v) => v.meta.type === MWMediaType.SERIES);
|
||||||
|
if (show?.meta.type !== MWMediaType.SERIES)
|
||||||
|
throw new Error("no show to test with");
|
||||||
|
const results = await runProvider(provider, {
|
||||||
|
media: show,
|
||||||
|
progress() {},
|
||||||
|
type: show.meta.type as MWMediaType.SERIES,
|
||||||
|
episode: show.meta.seasonData.episodes[0].id,
|
||||||
|
season: show.meta.seasons[0].id,
|
||||||
|
});
|
||||||
|
expect(results).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
45
src/__tests__/providers/testdata.ts
Normal file
45
src/__tests__/providers/testdata.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
|
||||||
|
export const testData: DetailedMeta[] = [
|
||||||
|
{
|
||||||
|
imdbId: "tt10954562",
|
||||||
|
tmdbId: "572716",
|
||||||
|
meta: {
|
||||||
|
id: "439596",
|
||||||
|
title: "Hamilton",
|
||||||
|
type: MWMediaType.MOVIE,
|
||||||
|
year: "2020",
|
||||||
|
seasons: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imdbId: "tt11126994",
|
||||||
|
tmdbId: "94605",
|
||||||
|
meta: {
|
||||||
|
id: "222333",
|
||||||
|
title: "Arcane",
|
||||||
|
type: MWMediaType.SERIES,
|
||||||
|
year: "2021",
|
||||||
|
seasons: [
|
||||||
|
{
|
||||||
|
id: "230301",
|
||||||
|
number: 1,
|
||||||
|
title: "Season 1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
seasonData: {
|
||||||
|
id: "230301",
|
||||||
|
number: 1,
|
||||||
|
title: "Season 1",
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
id: "4243445",
|
||||||
|
number: 1,
|
||||||
|
title: "Welcome to the Playground",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
1
src/backend/embeds/.gitkeep
Normal file
1
src/backend/embeds/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
embed scrapers go here
|
20
src/backend/embeds/playm4u.ts
Normal file
20
src/backend/embeds/playm4u.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||||
|
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||||
|
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||||
|
|
||||||
|
registerEmbedScraper({
|
||||||
|
id: "playm4u",
|
||||||
|
displayName: "playm4u",
|
||||||
|
for: MWEmbedType.PLAYM4U,
|
||||||
|
rank: 0,
|
||||||
|
async getStream() {
|
||||||
|
// throw new Error("Oh well 2")
|
||||||
|
return {
|
||||||
|
embedId: "",
|
||||||
|
streamUrl: "",
|
||||||
|
quality: MWStreamQuality.Q1080P,
|
||||||
|
captions: [],
|
||||||
|
type: MWStreamType.MP4,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
66
src/backend/embeds/streamm4u.ts
Normal file
66
src/backend/embeds/streamm4u.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||||
|
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||||
|
import {
|
||||||
|
MWStreamQuality,
|
||||||
|
MWStreamType,
|
||||||
|
MWEmbedStream,
|
||||||
|
} from "@/backend/helpers/streams";
|
||||||
|
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
|
|
||||||
|
const HOST = "streamm4u.club";
|
||||||
|
const URL_BASE = `https://${HOST}`;
|
||||||
|
const URL_API = `${URL_BASE}/api`;
|
||||||
|
const URL_API_SOURCE = `${URL_API}/source`;
|
||||||
|
|
||||||
|
async function scrape(embed: string) {
|
||||||
|
const sources: MWEmbedStream[] = [];
|
||||||
|
|
||||||
|
const embedID = embed.split("/").pop();
|
||||||
|
|
||||||
|
console.log(`${URL_API_SOURCE}/${embedID}`);
|
||||||
|
const json = await proxiedFetch<any>(`${URL_API_SOURCE}/${embedID}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: `r=&d=${HOST}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (json.success) {
|
||||||
|
const streams = json.data;
|
||||||
|
|
||||||
|
for (const stream of streams) {
|
||||||
|
sources.push({
|
||||||
|
embedId: "",
|
||||||
|
streamUrl: stream.file as string,
|
||||||
|
quality: stream.label as MWStreamQuality,
|
||||||
|
type: stream.type as MWStreamType,
|
||||||
|
captions: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO check out 403 / 404 on successfully returned video stream URLs
|
||||||
|
registerEmbedScraper({
|
||||||
|
id: "streamm4u",
|
||||||
|
displayName: "streamm4u",
|
||||||
|
for: MWEmbedType.STREAMM4U,
|
||||||
|
rank: 100,
|
||||||
|
async getStream({ progress, url }) {
|
||||||
|
// const scrapingThreads = [];
|
||||||
|
// const streams = [];
|
||||||
|
|
||||||
|
const sources = (await scrape(url)).sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", ""))
|
||||||
|
);
|
||||||
|
// const preferredSourceIndex = 0;
|
||||||
|
const preferredSource = sources[0];
|
||||||
|
|
||||||
|
if (!preferredSource) throw new Error("No source found");
|
||||||
|
|
||||||
|
progress(100);
|
||||||
|
|
||||||
|
return preferredSource;
|
||||||
|
},
|
||||||
|
});
|
52
src/backend/helpers/captions.ts
Normal file
52
src/backend/helpers/captions.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
|
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||||
|
import toWebVTT from "srt-webvtt";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
export const sanitize = DOMPurify.sanitize;
|
||||||
|
export const CUSTOM_CAPTION_ID = "customCaption";
|
||||||
|
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 async function convertCustomCaptionFileToWebVTT(file: File) {
|
||||||
|
const header = await file.slice(0, 6).text();
|
||||||
|
const isWebVTT = header === "WEBVTT";
|
||||||
|
if (!isWebVTT) {
|
||||||
|
return toWebVTT(file);
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeCaptionBlob(url: string | undefined) {
|
||||||
|
if (url && url.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
27
src/backend/helpers/embed.ts
Normal file
27
src/backend/helpers/embed.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { MWEmbedStream } from "./streams";
|
||||||
|
|
||||||
|
export enum MWEmbedType {
|
||||||
|
M4UFREE = "m4ufree",
|
||||||
|
STREAMM4U = "streamm4u",
|
||||||
|
PLAYM4U = "playm4u",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MWEmbed = {
|
||||||
|
type: MWEmbedType;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MWEmbedContext = {
|
||||||
|
progress(percentage: number): void;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MWEmbedScraper = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
for: MWEmbedType;
|
||||||
|
rank: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
getStream(ctx: MWEmbedContext): Promise<MWEmbedStream>;
|
||||||
|
};
|
60
src/backend/helpers/fetch.ts
Normal file
60
src/backend/helpers/fetch.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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>>;
|
||||||
|
|
||||||
|
const baseFetch = ofetch.create({
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function makeUrl(url: string, data: Record<string, string>) {
|
||||||
|
let parsedUrl: string = url;
|
||||||
|
Object.entries(data).forEach(([k, v]) => {
|
||||||
|
parsedUrl = parsedUrl.replace(`{${k}}`, encodeURIComponent(v));
|
||||||
|
});
|
||||||
|
return parsedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mwFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||||
|
return baseFetch<T>(url, ops);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||||
|
let combinedUrl = ops?.baseURL ?? "";
|
||||||
|
if (
|
||||||
|
combinedUrl.length > 0 &&
|
||||||
|
combinedUrl.endsWith("/") &&
|
||||||
|
url.startsWith("/")
|
||||||
|
)
|
||||||
|
combinedUrl += url.slice(1);
|
||||||
|
else if (
|
||||||
|
combinedUrl.length > 0 &&
|
||||||
|
!combinedUrl.endsWith("/") &&
|
||||||
|
!url.startsWith("/")
|
||||||
|
)
|
||||||
|
combinedUrl += `/${url}`;
|
||||||
|
else combinedUrl += url;
|
||||||
|
|
||||||
|
const parsedUrl = new URL(combinedUrl);
|
||||||
|
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
||||||
|
parsedUrl.searchParams.set(k, v);
|
||||||
|
});
|
||||||
|
|
||||||
|
return baseFetch<T>(getProxyUrl(), {
|
||||||
|
...ops,
|
||||||
|
baseURL: undefined,
|
||||||
|
params: {
|
||||||
|
destination: parsedUrl.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
36
src/backend/helpers/provider.ts
Normal file
36
src/backend/helpers/provider.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { DetailedMeta } from "../metadata/getmeta";
|
||||||
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
import { MWEmbed } from "./embed";
|
||||||
|
import { MWStream } from "./streams";
|
||||||
|
|
||||||
|
export type MWProviderScrapeResult = {
|
||||||
|
stream?: MWStream;
|
||||||
|
embeds: MWEmbed[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MWProviderBase = {
|
||||||
|
progress(percentage: number): void;
|
||||||
|
media: DetailedMeta;
|
||||||
|
};
|
||||||
|
type MWProviderTypeSpecific =
|
||||||
|
| {
|
||||||
|
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||||
|
episode?: undefined;
|
||||||
|
season?: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: MWMediaType.SERIES;
|
||||||
|
episode: string;
|
||||||
|
season: string;
|
||||||
|
};
|
||||||
|
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;
|
||||||
|
|
||||||
|
export type MWProvider = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
rank: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
type: MWMediaType[];
|
||||||
|
|
||||||
|
scrape(ctx: MWProviderContext): Promise<MWProviderScrapeResult>;
|
||||||
|
};
|
72
src/backend/helpers/register.ts
Normal file
72
src/backend/helpers/register.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { MWEmbedScraper, MWEmbedType } from "./embed";
|
||||||
|
import { MWProvider } from "./provider";
|
||||||
|
|
||||||
|
let providers: MWProvider[] = [];
|
||||||
|
let embeds: MWEmbedScraper[] = [];
|
||||||
|
|
||||||
|
export function registerProvider(provider: MWProvider) {
|
||||||
|
if (provider.disabled) return;
|
||||||
|
providers.push(provider);
|
||||||
|
}
|
||||||
|
export function registerEmbedScraper(embed: MWEmbedScraper) {
|
||||||
|
if (embed.disabled) return;
|
||||||
|
embeds.push(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeScraperStore() {
|
||||||
|
// sort by ranking
|
||||||
|
providers = providers.sort((a, b) => b.rank - a.rank);
|
||||||
|
embeds = embeds.sort((a, b) => b.rank - a.rank);
|
||||||
|
|
||||||
|
// check for invalid ranks
|
||||||
|
let lastRank: null | number = null;
|
||||||
|
providers.forEach((v) => {
|
||||||
|
if (lastRank === null) {
|
||||||
|
lastRank = v.rank;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastRank === v.rank)
|
||||||
|
throw new Error(`Duplicate rank number for provider ${v.id}`);
|
||||||
|
lastRank = v.rank;
|
||||||
|
});
|
||||||
|
lastRank = null;
|
||||||
|
providers.forEach((v) => {
|
||||||
|
if (lastRank === null) {
|
||||||
|
lastRank = v.rank;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastRank === v.rank)
|
||||||
|
throw new Error(`Duplicate rank number for embed scraper ${v.id}`);
|
||||||
|
lastRank = v.rank;
|
||||||
|
});
|
||||||
|
|
||||||
|
// check for duplicate ids
|
||||||
|
const providerIds = providers.map((v) => v.id);
|
||||||
|
if (
|
||||||
|
providerIds.length > 0 &&
|
||||||
|
new Set(providerIds).size !== providerIds.length
|
||||||
|
)
|
||||||
|
throw new Error("Duplicate IDS in providers");
|
||||||
|
const embedIds = embeds.map((v) => v.id);
|
||||||
|
if (embedIds.length > 0 && new Set(embedIds).size !== embedIds.length)
|
||||||
|
throw new Error("Duplicate IDS in embed scrapers");
|
||||||
|
|
||||||
|
// check for duplicate embed types
|
||||||
|
const embedTypes = embeds.map((v) => v.for);
|
||||||
|
if (embedTypes.length > 0 && new Set(embedTypes).size !== embedTypes.length)
|
||||||
|
throw new Error("Duplicate types in embed scrapers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviders(): MWProvider[] {
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEmbeds(): MWEmbedScraper[] {
|
||||||
|
return embeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEmbedScraperByType(
|
||||||
|
type: MWEmbedType
|
||||||
|
): MWEmbedScraper | null {
|
||||||
|
return getEmbeds().find((v) => v.for === type) ?? null;
|
||||||
|
}
|
52
src/backend/helpers/run.ts
Normal file
52
src/backend/helpers/run.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { MWEmbed, MWEmbedContext, MWEmbedScraper } from "./embed";
|
||||||
|
import {
|
||||||
|
MWProvider,
|
||||||
|
MWProviderContext,
|
||||||
|
MWProviderScrapeResult,
|
||||||
|
} from "./provider";
|
||||||
|
import { getEmbedScraperByType } from "./register";
|
||||||
|
import { MWStream } from "./streams";
|
||||||
|
|
||||||
|
function sortProviderResult(
|
||||||
|
ctx: MWProviderScrapeResult
|
||||||
|
): MWProviderScrapeResult {
|
||||||
|
ctx.embeds = ctx.embeds
|
||||||
|
.map<[MWEmbed, MWEmbedScraper | null]>((v) => [
|
||||||
|
v,
|
||||||
|
v.type ? getEmbedScraperByType(v.type) : null,
|
||||||
|
])
|
||||||
|
.sort(([, a], [, b]) => (b?.rank ?? 0) - (a?.rank ?? 0))
|
||||||
|
.map((v) => v[0]);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runProvider(
|
||||||
|
provider: MWProvider,
|
||||||
|
ctx: MWProviderContext
|
||||||
|
): Promise<MWProviderScrapeResult> {
|
||||||
|
try {
|
||||||
|
const data = await provider.scrape(ctx);
|
||||||
|
return sortProviderResult(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to run provider", err, {
|
||||||
|
id: provider.id,
|
||||||
|
ctx: { ...ctx },
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runEmbedScraper(
|
||||||
|
scraper: MWEmbedScraper,
|
||||||
|
ctx: MWEmbedContext
|
||||||
|
): Promise<MWStream> {
|
||||||
|
try {
|
||||||
|
return await scraper.getStream(ctx);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to run embed scraper", {
|
||||||
|
id: scraper.id,
|
||||||
|
ctx: { ...ctx },
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
173
src/backend/helpers/scrape.ts
Normal file
173
src/backend/helpers/scrape.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { MWProviderContext, MWProviderScrapeResult } from "./provider";
|
||||||
|
import { getEmbedScraperByType, getProviders } from "./register";
|
||||||
|
import { runEmbedScraper, runProvider } from "./run";
|
||||||
|
import { MWStream } from "./streams";
|
||||||
|
import { DetailedMeta } from "../metadata/getmeta";
|
||||||
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
|
||||||
|
interface MWProgressData {
|
||||||
|
type: "embed" | "provider";
|
||||||
|
id: string;
|
||||||
|
eventId: string;
|
||||||
|
percentage: number;
|
||||||
|
errored: boolean;
|
||||||
|
}
|
||||||
|
interface MWNextData {
|
||||||
|
id: string;
|
||||||
|
eventId: string;
|
||||||
|
type: "embed" | "provider";
|
||||||
|
}
|
||||||
|
|
||||||
|
type MWProviderRunContextBase = {
|
||||||
|
media: DetailedMeta;
|
||||||
|
onProgress?: (data: MWProgressData) => void;
|
||||||
|
onNext?: (data: MWNextData) => void;
|
||||||
|
};
|
||||||
|
type MWProviderRunContextTypeSpecific =
|
||||||
|
| {
|
||||||
|
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||||
|
episode: undefined;
|
||||||
|
season: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: MWMediaType.SERIES;
|
||||||
|
episode: string;
|
||||||
|
season: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MWProviderRunContext = MWProviderRunContextBase &
|
||||||
|
MWProviderRunContextTypeSpecific;
|
||||||
|
|
||||||
|
async function findBestEmbedStream(
|
||||||
|
result: MWProviderScrapeResult,
|
||||||
|
providerId: string,
|
||||||
|
ctx: MWProviderRunContext
|
||||||
|
): Promise<MWStream | null> {
|
||||||
|
if (result.stream) {
|
||||||
|
return {
|
||||||
|
...result.stream,
|
||||||
|
providerId,
|
||||||
|
embedId: providerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let embedNum = 0;
|
||||||
|
for (const embed of result.embeds) {
|
||||||
|
embedNum += 1;
|
||||||
|
if (!embed.type) continue;
|
||||||
|
const scraper = getEmbedScraperByType(embed.type);
|
||||||
|
if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`);
|
||||||
|
|
||||||
|
const eventId = [providerId, scraper.id, embedNum].join("|");
|
||||||
|
|
||||||
|
ctx.onNext?.({ id: scraper.id, type: "embed", eventId });
|
||||||
|
|
||||||
|
let stream: MWStream;
|
||||||
|
try {
|
||||||
|
stream = await runEmbedScraper(scraper, {
|
||||||
|
url: embed.url,
|
||||||
|
progress(num) {
|
||||||
|
ctx.onProgress?.({
|
||||||
|
errored: false,
|
||||||
|
eventId,
|
||||||
|
id: scraper.id,
|
||||||
|
percentage: num,
|
||||||
|
type: "embed",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
ctx.onProgress?.({
|
||||||
|
errored: true,
|
||||||
|
eventId,
|
||||||
|
id: scraper.id,
|
||||||
|
percentage: 100,
|
||||||
|
type: "embed",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.onProgress?.({
|
||||||
|
errored: false,
|
||||||
|
eventId,
|
||||||
|
id: scraper.id,
|
||||||
|
percentage: 100,
|
||||||
|
type: "embed",
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.providerId = providerId;
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findBestStream(
|
||||||
|
ctx: MWProviderRunContext
|
||||||
|
): Promise<MWStream | null> {
|
||||||
|
const providers = getProviders();
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const eventId = provider.id;
|
||||||
|
ctx.onNext?.({ id: provider.id, type: "provider", eventId });
|
||||||
|
let result: MWProviderScrapeResult;
|
||||||
|
try {
|
||||||
|
let context: MWProviderContext;
|
||||||
|
if (ctx.type === MWMediaType.SERIES) {
|
||||||
|
context = {
|
||||||
|
media: ctx.media,
|
||||||
|
type: ctx.type,
|
||||||
|
episode: ctx.episode,
|
||||||
|
season: ctx.season,
|
||||||
|
progress(num) {
|
||||||
|
ctx.onProgress?.({
|
||||||
|
percentage: num,
|
||||||
|
eventId,
|
||||||
|
errored: false,
|
||||||
|
id: provider.id,
|
||||||
|
type: "provider",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
context = {
|
||||||
|
media: ctx.media,
|
||||||
|
type: ctx.type,
|
||||||
|
progress(num) {
|
||||||
|
ctx.onProgress?.({
|
||||||
|
percentage: num,
|
||||||
|
eventId,
|
||||||
|
errored: false,
|
||||||
|
id: provider.id,
|
||||||
|
type: "provider",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
result = await runProvider(provider, context);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.onProgress?.({
|
||||||
|
percentage: 100,
|
||||||
|
errored: true,
|
||||||
|
eventId,
|
||||||
|
id: provider.id,
|
||||||
|
type: "provider",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.onProgress?.({
|
||||||
|
errored: false,
|
||||||
|
id: provider.id,
|
||||||
|
eventId,
|
||||||
|
percentage: 100,
|
||||||
|
type: "provider",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await findBestEmbedStream(result, provider.id, ctx);
|
||||||
|
if (!stream) continue;
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
38
src/backend/helpers/streams.ts
Normal file
38
src/backend/helpers/streams.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export enum MWStreamType {
|
||||||
|
MP4 = "mp4",
|
||||||
|
HLS = "hls",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MWCaptionType {
|
||||||
|
VTT = "vtt",
|
||||||
|
SRT = "srt",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MWStreamQuality {
|
||||||
|
Q360P = "360p",
|
||||||
|
Q540P = "540p",
|
||||||
|
Q480P = "480p",
|
||||||
|
Q720P = "720p",
|
||||||
|
Q1080P = "1080p",
|
||||||
|
QUNKNOWN = "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MWCaption = {
|
||||||
|
needsProxy?: boolean;
|
||||||
|
url: string;
|
||||||
|
type: MWCaptionType;
|
||||||
|
langIso: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MWStream = {
|
||||||
|
streamUrl: string;
|
||||||
|
type: MWStreamType;
|
||||||
|
quality: MWStreamQuality;
|
||||||
|
providerId?: string;
|
||||||
|
embedId?: string;
|
||||||
|
captions: MWCaption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MWEmbedStream = MWStream & {
|
||||||
|
embedId: string;
|
||||||
|
};
|
14
src/backend/index.ts
Normal file
14
src/backend/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { initializeScraperStore } from "./helpers/register";
|
||||||
|
|
||||||
|
// providers
|
||||||
|
import "./providers/gdriveplayer";
|
||||||
|
import "./providers/flixhq";
|
||||||
|
import "./providers/superstream";
|
||||||
|
import "./providers/netfilm";
|
||||||
|
import "./providers/m4ufree";
|
||||||
|
|
||||||
|
// embeds
|
||||||
|
import "./embeds/streamm4u";
|
||||||
|
import "./embeds/playm4u";
|
||||||
|
|
||||||
|
initializeScraperStore();
|
85
src/backend/metadata/getmeta.ts
Normal file
85
src/backend/metadata/getmeta.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { FetchError } from "ofetch";
|
||||||
|
import { makeUrl, proxiedFetch } from "../helpers/fetch";
|
||||||
|
import {
|
||||||
|
formatJWMeta,
|
||||||
|
JWMediaResult,
|
||||||
|
JWSeasonMetaResult,
|
||||||
|
JW_API_BASE,
|
||||||
|
mediaTypeToJW,
|
||||||
|
} from "./justwatch";
|
||||||
|
import { MWMediaMeta, MWMediaType } from "./types";
|
||||||
|
|
||||||
|
type JWExternalIdType =
|
||||||
|
| "eidr"
|
||||||
|
| "imdb_latest"
|
||||||
|
| "imdb"
|
||||||
|
| "tmdb_latest"
|
||||||
|
| "tmdb"
|
||||||
|
| "tms";
|
||||||
|
|
||||||
|
interface JWExternalId {
|
||||||
|
provider: JWExternalIdType;
|
||||||
|
external_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JWDetailedMeta extends JWMediaResult {
|
||||||
|
external_ids: JWExternalId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailedMeta {
|
||||||
|
meta: MWMediaMeta;
|
||||||
|
tmdbId: string;
|
||||||
|
imdbId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMetaFromId(
|
||||||
|
type: MWMediaType,
|
||||||
|
id: string,
|
||||||
|
seasonId?: string
|
||||||
|
): Promise<DetailedMeta | null> {
|
||||||
|
const queryType = mediaTypeToJW(type);
|
||||||
|
|
||||||
|
let data: JWDetailedMeta;
|
||||||
|
try {
|
||||||
|
const url = makeUrl("/content/titles/{type}/{id}/locale/en_US", {
|
||||||
|
type: queryType,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
data = await proxiedFetch<JWDetailedMeta>(url, { baseURL: JW_API_BASE });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FetchError) {
|
||||||
|
// 400 and 404 are treated as not found
|
||||||
|
if (err.statusCode === 400 || err.statusCode === 404) return null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let imdbId = data.external_ids.find(
|
||||||
|
(v) => v.provider === "imdb_latest"
|
||||||
|
)?.external_id;
|
||||||
|
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");
|
||||||
|
|
||||||
|
let seasonData: JWSeasonMetaResult | undefined;
|
||||||
|
if (data.object_type === "show") {
|
||||||
|
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";
|
||||||
|
const url = makeUrl("/content/titles/show_season/{id}/locale/en_US", {
|
||||||
|
id: seasonToScrape,
|
||||||
|
});
|
||||||
|
seasonData = await proxiedFetch<any>(url, { baseURL: JW_API_BASE });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: formatJWMeta(data, seasonData),
|
||||||
|
imdbId,
|
||||||
|
tmdbId,
|
||||||
|
};
|
||||||
|
}
|
112
src/backend/metadata/justwatch.ts
Normal file
112
src/backend/metadata/justwatch.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types";
|
||||||
|
|
||||||
|
export const JW_API_BASE = "https://apis.justwatch.com";
|
||||||
|
export const JW_IMAGE_BASE = "https://images.justwatch.com";
|
||||||
|
|
||||||
|
export type JWContentTypes = "movie" | "show";
|
||||||
|
|
||||||
|
export type JWSeasonShort = {
|
||||||
|
title: string;
|
||||||
|
id: number;
|
||||||
|
season_number: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JWEpisodeShort = {
|
||||||
|
title: string;
|
||||||
|
id: number;
|
||||||
|
episode_number: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JWMediaResult = {
|
||||||
|
title: string;
|
||||||
|
poster?: string;
|
||||||
|
id: number;
|
||||||
|
original_release_year?: number;
|
||||||
|
jw_entity_id: string;
|
||||||
|
object_type: JWContentTypes;
|
||||||
|
seasons?: JWSeasonShort[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JWSeasonMetaResult = {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
season_number: number;
|
||||||
|
episodes: JWEpisodeShort[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
|
||||||
|
if (type === MWMediaType.MOVIE) return "movie";
|
||||||
|
if (type === MWMediaType.SERIES) return "show";
|
||||||
|
throw new Error("unsupported type");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JWMediaToMediaType(type: string): MWMediaType {
|
||||||
|
if (type === "movie") return MWMediaType.MOVIE;
|
||||||
|
if (type === "show") return MWMediaType.SERIES;
|
||||||
|
throw new Error("unsupported type");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatJWMeta(
|
||||||
|
media: JWMediaResult,
|
||||||
|
season?: JWSeasonMetaResult
|
||||||
|
): MWMediaMeta {
|
||||||
|
const type = JWMediaToMediaType(media.object_type);
|
||||||
|
let seasons: undefined | MWSeasonMeta[];
|
||||||
|
if (type === MWMediaType.SERIES) {
|
||||||
|
seasons = media.seasons
|
||||||
|
?.sort((a, b) => a.season_number - b.season_number)
|
||||||
|
.map(
|
||||||
|
(v): MWSeasonMeta => ({
|
||||||
|
id: v.id.toString(),
|
||||||
|
number: v.season_number,
|
||||||
|
title: v.title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: media.title,
|
||||||
|
id: media.id.toString(),
|
||||||
|
year: media.original_release_year?.toString(),
|
||||||
|
poster: media.poster
|
||||||
|
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
|
||||||
|
: undefined,
|
||||||
|
type,
|
||||||
|
seasons: seasons as any,
|
||||||
|
seasonData: season
|
||||||
|
? ({
|
||||||
|
id: season.id.toString(),
|
||||||
|
number: season.season_number,
|
||||||
|
title: season.title,
|
||||||
|
episodes: season.episodes
|
||||||
|
.sort((a, b) => a.episode_number - b.episode_number)
|
||||||
|
.map((v) => ({
|
||||||
|
id: v.id.toString(),
|
||||||
|
number: v.episode_number,
|
||||||
|
title: v.title,
|
||||||
|
})),
|
||||||
|
} as any)
|
||||||
|
: (undefined as any),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JWMediaToId(media: MWMediaMeta): string {
|
||||||
|
return ["JW", mediaTypeToJW(media.type), media.id].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeJWId(
|
||||||
|
paramId: string
|
||||||
|
): { id: string; type: MWMediaType } | null {
|
||||||
|
const [prefix, type, id] = paramId.split("-", 3);
|
||||||
|
if (prefix !== "JW") return null;
|
||||||
|
let mediaType;
|
||||||
|
try {
|
||||||
|
mediaType = JWMediaToMediaType(type);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: mediaType,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
}
|
58
src/backend/metadata/search.ts
Normal file
58
src/backend/metadata/search.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { SimpleCache } from "@/utils/cache";
|
||||||
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
import {
|
||||||
|
formatJWMeta,
|
||||||
|
JWContentTypes,
|
||||||
|
JWMediaResult,
|
||||||
|
JW_API_BASE,
|
||||||
|
mediaTypeToJW,
|
||||||
|
} from "./justwatch";
|
||||||
|
import { MWMediaMeta, MWQuery } from "./types";
|
||||||
|
|
||||||
|
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
||||||
|
cache.setCompare((a, b) => {
|
||||||
|
return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim();
|
||||||
|
});
|
||||||
|
cache.initialize();
|
||||||
|
|
||||||
|
type JWSearchQuery = {
|
||||||
|
content_types: JWContentTypes[];
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JWPage<T> = {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_pages: number;
|
||||||
|
total_results: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
|
||||||
|
if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
|
||||||
|
const { searchQuery, type } = query;
|
||||||
|
|
||||||
|
const contentType = mediaTypeToJW(type);
|
||||||
|
const body: JWSearchQuery = {
|
||||||
|
content_types: [contentType],
|
||||||
|
page: 1,
|
||||||
|
query: searchQuery,
|
||||||
|
page_size: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await proxiedFetch<JWPage<JWMediaResult>>(
|
||||||
|
"/content/titles/en_US/popular",
|
||||||
|
{
|
||||||
|
baseURL: JW_API_BASE,
|
||||||
|
params: {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const returnData = data.items.map<MWMediaMeta>((v) => formatJWMeta(v));
|
||||||
|
cache.set(query, returnData, 3600); // cache for an hour
|
||||||
|
return returnData;
|
||||||
|
}
|
47
src/backend/metadata/types.ts
Normal file
47
src/backend/metadata/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export enum MWMediaType {
|
||||||
|
MOVIE = "movie",
|
||||||
|
SERIES = "series",
|
||||||
|
ANIME = "anime",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MWSeasonMeta = {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MWSeasonWithEpisodeMeta = {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
episodes: {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MWMediaMetaBase = {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
year?: string;
|
||||||
|
poster?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MWMediaMetaSpecific =
|
||||||
|
| {
|
||||||
|
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||||
|
seasons: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: MWMediaType.SERIES;
|
||||||
|
seasons: MWSeasonMeta[];
|
||||||
|
seasonData: MWSeasonWithEpisodeMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific;
|
||||||
|
|
||||||
|
export interface MWQuery {
|
||||||
|
searchQuery: string;
|
||||||
|
type: MWMediaType;
|
||||||
|
}
|
146
src/backend/providers/flixhq.ts
Normal file
146
src/backend/providers/flixhq.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { compareTitle } from "@/utils/titleMatch";
|
||||||
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
import { registerProvider } from "../helpers/register";
|
||||||
|
import {
|
||||||
|
MWCaptionType,
|
||||||
|
MWStreamQuality,
|
||||||
|
MWStreamType,
|
||||||
|
} from "../helpers/streams";
|
||||||
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
|
||||||
|
// const flixHqBase = "https://api.consumet.org/movies/flixhq";
|
||||||
|
// *** TEMPORARY FIX - use other instance
|
||||||
|
// SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326
|
||||||
|
const flixHqBase = "https://c.delusionz.xyz/movies/flixhq";
|
||||||
|
|
||||||
|
interface FLIXMediaBase {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
image: string;
|
||||||
|
type: "Movie" | "TV Series";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FLIXTVSerie extends FLIXMediaBase {
|
||||||
|
seasons: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FLIXMovie extends FLIXMediaBase {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerProvider({
|
||||||
|
id: "flixhq",
|
||||||
|
displayName: "FlixHQ",
|
||||||
|
rank: 100,
|
||||||
|
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)}`,
|
||||||
|
{
|
||||||
|
baseURL: flixHqBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
||||||
|
if (media.meta.type === MWMediaType.MOVIE) {
|
||||||
|
if (v.type !== "Movie") return false;
|
||||||
|
const movie = v as FLIXMovie;
|
||||||
|
return (
|
||||||
|
compareTitle(movie.title, media.meta.title) &&
|
||||||
|
movie.releaseDate === media.meta.year
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (media.meta.type === MWMediaType.SERIES) {
|
||||||
|
if (v.type !== "TV Series") return false;
|
||||||
|
const serie = v as FLIXTVSerie;
|
||||||
|
if (serie.seasons && media.meta.seasons) {
|
||||||
|
return (
|
||||||
|
compareTitle(serie.title, media.meta.title) &&
|
||||||
|
serie.seasons === media.meta.seasons.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (!foundItem) throw new Error("No watchable item found");
|
||||||
|
const flixId = foundItem.id;
|
||||||
|
|
||||||
|
// get media info
|
||||||
|
progress(25);
|
||||||
|
const mediaInfo = await proxiedFetch<any>("/info", {
|
||||||
|
baseURL: flixHqBase,
|
||||||
|
params: {
|
||||||
|
id: flixId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!mediaInfo.episodes) throw new Error("No watchable item found");
|
||||||
|
// get stream info from media
|
||||||
|
progress(75);
|
||||||
|
|
||||||
|
// By default we assume it is a movie
|
||||||
|
let episodeId: string | undefined = mediaInfo.episodes[0].id;
|
||||||
|
if (media.meta.type === MWMediaType.SERIES) {
|
||||||
|
const seasonNo = media.meta.seasonData.number;
|
||||||
|
const episodeNo = media.meta.seasonData.episodes.find(
|
||||||
|
(e) => e.id === episode
|
||||||
|
)?.number;
|
||||||
|
episodeId = mediaInfo.episodes.find(
|
||||||
|
(e: any) => e.season === seasonNo && e.number === episodeNo
|
||||||
|
)?.id;
|
||||||
|
}
|
||||||
|
if (!episodeId) throw new Error("No watchable item found");
|
||||||
|
|
||||||
|
const watchInfo = await proxiedFetch<any>("/watch", {
|
||||||
|
baseURL: flixHqBase,
|
||||||
|
params: {
|
||||||
|
episodeId,
|
||||||
|
mediaId: flixId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!watchInfo.sources) throw new Error("No watchable item found");
|
||||||
|
|
||||||
|
// get best quality source
|
||||||
|
// comes sorted by quality in descending order
|
||||||
|
const source = watchInfo.sources[0];
|
||||||
|
return {
|
||||||
|
embeds: [],
|
||||||
|
stream: {
|
||||||
|
streamUrl: source.url,
|
||||||
|
quality: qualityMap[source.quality],
|
||||||
|
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
||||||
|
captions: watchInfo.subtitles
|
||||||
|
.filter(
|
||||||
|
(x: { url: string; lang: string }) => !x.lang.includes("(maybe)")
|
||||||
|
)
|
||||||
|
.map(castSubtitles),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@@ -1,15 +1,10 @@
|
|||||||
import { unpack } from "unpacker";
|
import { unpack } from "unpacker";
|
||||||
import CryptoJS from "crypto-js";
|
import CryptoJS from "crypto-js";
|
||||||
import {
|
|
||||||
MWMediaProvider,
|
|
||||||
MWMediaType,
|
|
||||||
MWPortableMedia,
|
|
||||||
MWMediaStream,
|
|
||||||
MWQuery,
|
|
||||||
MWProviderMediaResult,
|
|
||||||
} from "@/providers/types";
|
|
||||||
|
|
||||||
import { CORS_PROXY_URL } from "@/mw_constants";
|
import { registerProvider } from "@/backend/helpers/register";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
import { MWStreamQuality } from "@/backend/helpers/streams";
|
||||||
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
|
||||||
const format = {
|
const format = {
|
||||||
stringify: (cipher: any) => {
|
stringify: (cipher: any) => {
|
||||||
@@ -37,46 +32,24 @@ const format = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gDrivePlayerScraper: MWMediaProvider = {
|
registerProvider({
|
||||||
id: "gdriveplayer",
|
id: "gdriveplayer",
|
||||||
enabled: true,
|
|
||||||
type: [MWMediaType.MOVIE],
|
|
||||||
displayName: "gdriveplayer",
|
displayName: "gdriveplayer",
|
||||||
|
disabled: true,
|
||||||
|
rank: 69,
|
||||||
|
type: [MWMediaType.MOVIE],
|
||||||
|
|
||||||
async getMediaFromPortable(
|
async scrape({ progress, media: { imdbId } }) {
|
||||||
media: MWPortableMedia
|
progress(10);
|
||||||
): Promise<MWProviderMediaResult> {
|
const streamRes = await proxiedFetch<string>(
|
||||||
const res = await fetch(
|
"https://database.gdriveplayer.us/player.php",
|
||||||
`${CORS_PROXY_URL}https://api.gdriveplayer.us/v1/imdb/${media.mediaId}`
|
{
|
||||||
).then((d) => d.json());
|
params: {
|
||||||
|
imdb: imdbId,
|
||||||
return {
|
|
||||||
...media,
|
|
||||||
title: res.Title,
|
|
||||||
year: res.Year,
|
|
||||||
} as MWProviderMediaResult;
|
|
||||||
},
|
},
|
||||||
|
}
|
||||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
|
|
||||||
const searchRes = await fetch(
|
|
||||||
`${CORS_PROXY_URL}https://api.gdriveplayer.us/v1/movie/search?title=${query.searchQuery}`
|
|
||||||
).then((d) => d.json());
|
|
||||||
|
|
||||||
const results: MWProviderMediaResult[] = (searchRes || []).map(
|
|
||||||
(item: any) => ({
|
|
||||||
title: item.title,
|
|
||||||
year: item.year,
|
|
||||||
mediaId: item.imdb,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
progress(90);
|
||||||
return results;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
|
||||||
const streamRes = await fetch(
|
|
||||||
`${CORS_PROXY_URL}https://database.gdriveplayer.us/player.php?imdb=${media.mediaId}`
|
|
||||||
).then((d) => d.text());
|
|
||||||
const page = new DOMParser().parseFromString(streamRes, "text/html");
|
const page = new DOMParser().parseFromString(streamRes, "text/html");
|
||||||
|
|
||||||
const script: HTMLElement | undefined = Array.from(
|
const script: HTMLElement | undefined = Array.from(
|
||||||
@@ -99,6 +72,7 @@ export const gDrivePlayerScraper: MWMediaProvider = {
|
|||||||
{ format }
|
{ format }
|
||||||
).toString(CryptoJS.enc.Utf8)
|
).toString(CryptoJS.enc.Utf8)
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const sources = JSON.parse(
|
const sources = JSON.parse(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -114,6 +88,18 @@ export const gDrivePlayerScraper: MWMediaProvider = {
|
|||||||
const source = sources[sources.length - 1];
|
const source = sources[sources.length - 1];
|
||||||
/// END
|
/// END
|
||||||
|
|
||||||
return { url: `https:${source.file}`, type: source.type, captions: [] };
|
let quality;
|
||||||
|
if (source.label === "720p") quality = MWStreamQuality.Q720P;
|
||||||
|
else quality = MWStreamQuality.QUNKNOWN;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: {
|
||||||
|
streamUrl: `https:${source.file}`,
|
||||||
|
type: source.type,
|
||||||
|
quality,
|
||||||
|
captions: [],
|
||||||
},
|
},
|
||||||
};
|
embeds: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
235
src/backend/providers/m4ufree.ts
Normal file
235
src/backend/providers/m4ufree.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
||||||
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
import { registerProvider } from "../helpers/register";
|
||||||
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
|
||||||
|
const HOST = "m4ufree.com";
|
||||||
|
const URL_BASE = `https://${HOST}`;
|
||||||
|
const URL_SEARCH = `${URL_BASE}/search`;
|
||||||
|
const URL_AJAX = `${URL_BASE}/ajax`;
|
||||||
|
const URL_AJAX_TV = `${URL_BASE}/ajaxtv`;
|
||||||
|
|
||||||
|
// * Years can be in one of 4 formats:
|
||||||
|
// * - "startyear" (for movies, EX: 2022)
|
||||||
|
// * - "startyear-" (for TV series which has not ended, EX: 2022-)
|
||||||
|
// * - "startyear-endyear" (for TV series which has ended, EX: 2022-2023)
|
||||||
|
// * - "startyearendyear" (for TV series which has ended, EX: 20222023)
|
||||||
|
const REGEX_TITLE_AND_YEAR = /(.*) \(?(\d*|\d*-|\d*-\d*)\)?$/;
|
||||||
|
const REGEX_TYPE = /.*-(movie|tvshow)-online-free-m4ufree\.html/;
|
||||||
|
const REGEX_COOKIES = /XSRF-TOKEN=(.*?);.*laravel_session=(.*?);/;
|
||||||
|
const REGEX_SEASON_EPISODE = /S(\d*)-E(\d*)/;
|
||||||
|
|
||||||
|
function toDom(html: string) {
|
||||||
|
return new DOMParser().parseFromString(html, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProvider({
|
||||||
|
id: "m4ufree",
|
||||||
|
displayName: "m4ufree",
|
||||||
|
rank: -1,
|
||||||
|
disabled: true, // Disables because the redirector URLs it returns will throw 404 / 403 depending on if you view it in the browser or fetch it respectively. It just does not work.
|
||||||
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
|
|
||||||
|
async scrape({ media, type, episode: episodeId, season: seasonId }) {
|
||||||
|
const season =
|
||||||
|
media.meta.seasons?.find((s) => s.id === seasonId)?.number || 1;
|
||||||
|
const episode =
|
||||||
|
media.meta.type === MWMediaType.SERIES
|
||||||
|
? media.meta.seasonData.episodes.find((ep) => ep.id === episodeId)
|
||||||
|
?.number || 1
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const embeds: MWEmbed[] = [];
|
||||||
|
|
||||||
|
/*
|
||||||
|
, {
|
||||||
|
responseType: "text" as any,
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
const responseText = await proxiedFetch<string>(
|
||||||
|
`${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html`
|
||||||
|
);
|
||||||
|
let dom = toDom(responseText);
|
||||||
|
|
||||||
|
const searchResults = [...dom.querySelectorAll(".item")]
|
||||||
|
.map((element) => {
|
||||||
|
const tooltipText = element.querySelector(".tiptitle p")?.innerHTML;
|
||||||
|
if (!tooltipText) return;
|
||||||
|
|
||||||
|
let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText);
|
||||||
|
|
||||||
|
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = regexResult[1];
|
||||||
|
const year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year
|
||||||
|
const a = element.querySelector("a");
|
||||||
|
if (!a) return;
|
||||||
|
const href = a.href;
|
||||||
|
|
||||||
|
regexResult = REGEX_TYPE.exec(href);
|
||||||
|
|
||||||
|
if (!regexResult || !regexResult[1]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scraperDeterminedType = regexResult[1];
|
||||||
|
|
||||||
|
scraperDeterminedType =
|
||||||
|
scraperDeterminedType === "tvshow" ? "show" : "movie"; // * Map to Trakt type
|
||||||
|
|
||||||
|
return { type: scraperDeterminedType, title, year, href };
|
||||||
|
})
|
||||||
|
.filter((item) => item);
|
||||||
|
|
||||||
|
const mediaInResults = searchResults.find(
|
||||||
|
(item) =>
|
||||||
|
item &&
|
||||||
|
item.title === media.meta.title &&
|
||||||
|
item.year.toString() === media.meta.year
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mediaInResults) {
|
||||||
|
// * Nothing found
|
||||||
|
return {
|
||||||
|
embeds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookies: string | null = "";
|
||||||
|
const responseTextFromMedia = await proxiedFetch<string>(
|
||||||
|
mediaInResults.href,
|
||||||
|
{
|
||||||
|
onResponse(context) {
|
||||||
|
cookies = context.response.headers.get("X-Set-Cookie");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
dom = toDom(responseTextFromMedia);
|
||||||
|
|
||||||
|
let regexResult = REGEX_COOKIES.exec(cookies);
|
||||||
|
|
||||||
|
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||||
|
// * DO SOMETHING?
|
||||||
|
throw new Error("No regexResults, yikesssssss kinda gross idk");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieHeader = `XSRF-TOKEN=${regexResult[1]}; laravel_session=${regexResult[2]}`;
|
||||||
|
|
||||||
|
const token = dom
|
||||||
|
.querySelector('meta[name="csrf-token"]')
|
||||||
|
?.getAttribute("content");
|
||||||
|
if (!token) return { embeds };
|
||||||
|
|
||||||
|
if (type === MWMediaType.SERIES) {
|
||||||
|
// * Get the season/episode data
|
||||||
|
const episodes = [...dom.querySelectorAll(".episode")]
|
||||||
|
.map((element) => {
|
||||||
|
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML);
|
||||||
|
|
||||||
|
if (!regexResult || !regexResult[1] || !regexResult[2]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEpisode = Number(regexResult[1]);
|
||||||
|
const newSeason = Number(regexResult[2]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: element.getAttribute("idepisode"),
|
||||||
|
episode: newEpisode,
|
||||||
|
season: newSeason,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => item);
|
||||||
|
|
||||||
|
const ep = episodes.find(
|
||||||
|
(newEp) => newEp && newEp.episode === episode && newEp.season === season
|
||||||
|
);
|
||||||
|
if (!ep) return { embeds };
|
||||||
|
|
||||||
|
const form = `idepisode=${ep.id}&_token=${token}`;
|
||||||
|
|
||||||
|
const response = await proxiedFetch<string>(URL_AJAX_TV, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "*/*",
|
||||||
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Sec-CH-UA":
|
||||||
|
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||||
|
"Sec-CH-UA-Mobile": "?0",
|
||||||
|
"Sec-CH-UA-Platform": '"Linux"',
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"X-Cookie": cookieHeader,
|
||||||
|
"X-Origin": URL_BASE,
|
||||||
|
"X-Referer": mediaInResults.href,
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
dom = toDom(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = [...dom.querySelectorAll(".singlemv")].map((element) =>
|
||||||
|
element.getAttribute("data")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const form = `m4u=${server}&_token=${token}`;
|
||||||
|
|
||||||
|
const response = await proxiedFetch<string>(URL_AJAX, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "*/*",
|
||||||
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Sec-CH-UA":
|
||||||
|
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
|
||||||
|
"Sec-CH-UA-Mobile": "?0",
|
||||||
|
"Sec-CH-UA-Platform": '"Linux"',
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"X-Cookie": cookieHeader,
|
||||||
|
"X-Origin": URL_BASE,
|
||||||
|
"X-Referer": mediaInResults.href,
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverDom = toDom(response);
|
||||||
|
|
||||||
|
const link = serverDom.querySelector("iframe")?.src;
|
||||||
|
|
||||||
|
const getEmbedType = (url: string) => {
|
||||||
|
if (url.startsWith("https://streamm4u.club"))
|
||||||
|
return MWEmbedType.STREAMM4U;
|
||||||
|
if (url.startsWith("https://play.playm4u.xyz"))
|
||||||
|
return MWEmbedType.PLAYM4U;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!link) continue;
|
||||||
|
|
||||||
|
const embedType = getEmbedType(link);
|
||||||
|
if (embedType) {
|
||||||
|
embeds.push({
|
||||||
|
url: link,
|
||||||
|
type: embedType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(embeds);
|
||||||
|
return {
|
||||||
|
embeds,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
153
src/backend/providers/netfilm.ts
Normal file
153
src/backend/providers/netfilm.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
import { registerProvider } from "../helpers/register";
|
||||||
|
import {
|
||||||
|
MWCaptionType,
|
||||||
|
MWStreamQuality,
|
||||||
|
MWStreamType,
|
||||||
|
} from "../helpers/streams";
|
||||||
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
|
||||||
|
const netfilmBase = "https://net-film.vercel.app";
|
||||||
|
|
||||||
|
const qualityMap: Record<number, MWStreamQuality> = {
|
||||||
|
360: MWStreamQuality.Q360P,
|
||||||
|
540: MWStreamQuality.Q540P,
|
||||||
|
480: MWStreamQuality.Q480P,
|
||||||
|
720: MWStreamQuality.Q720P,
|
||||||
|
1080: MWStreamQuality.Q1080P,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerProvider({
|
||||||
|
id: "netfilm",
|
||||||
|
displayName: "NetFilm",
|
||||||
|
rank: 15,
|
||||||
|
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 searchResponse = await proxiedFetch<any>(
|
||||||
|
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
|
||||||
|
{
|
||||||
|
baseURL: netfilmBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchResults = searchResponse.data.results;
|
||||||
|
progress(25);
|
||||||
|
|
||||||
|
if (media.meta.type === MWMediaType.MOVIE) {
|
||||||
|
const foundItem = searchResults.find((v: any) => {
|
||||||
|
return v.name === media.meta.title && v.releaseTime === media.meta.year;
|
||||||
|
});
|
||||||
|
if (!foundItem) throw new Error("No watchable item found");
|
||||||
|
const netfilmId = foundItem.id;
|
||||||
|
|
||||||
|
// get stream info from media
|
||||||
|
progress(75);
|
||||||
|
const watchInfo = await proxiedFetch<any>(
|
||||||
|
`/api/episode?id=${netfilmId}`,
|
||||||
|
{
|
||||||
|
baseURL: netfilmBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = watchInfo.data;
|
||||||
|
|
||||||
|
// get best quality source
|
||||||
|
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
|
||||||
|
.replace("akm-cdn", "aws-cdn")
|
||||||
|
.replace("gg-cdn", "aws-cdn"),
|
||||||
|
quality: qualityMap[source.quality],
|
||||||
|
type: MWStreamType.HLS,
|
||||||
|
captions: mappedCaptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.meta.type !== MWMediaType.SERIES)
|
||||||
|
throw new Error("Unsupported type");
|
||||||
|
|
||||||
|
const desiredSeason = media.meta.seasonData.number;
|
||||||
|
|
||||||
|
const searchItems = searchResults
|
||||||
|
.filter((v: any) => {
|
||||||
|
return v.name.includes(media.meta.title);
|
||||||
|
})
|
||||||
|
.map((v: any) => {
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
season: parseInt(v.name.split(" ").at(-1), 10) || 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundItem = searchItems.find((v: any) => {
|
||||||
|
return v.season === desiredSeason;
|
||||||
|
});
|
||||||
|
|
||||||
|
progress(50);
|
||||||
|
const seasonDetail = await proxiedFetch<any>(
|
||||||
|
`/api/detail?id=${foundItem.id}&category=${foundItem.categoryTag[0].id}`,
|
||||||
|
{
|
||||||
|
baseURL: netfilmBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const episodeNo = media.meta.seasonData.episodes.find(
|
||||||
|
(v: any) => v.id === episode
|
||||||
|
)?.number;
|
||||||
|
const episodeData = seasonDetail.data.episodeVo.find(
|
||||||
|
(v: any) => v.seriesNo === episodeNo
|
||||||
|
);
|
||||||
|
|
||||||
|
progress(75);
|
||||||
|
const episodeStream = await proxiedFetch<any>(
|
||||||
|
`/api/episode?id=${foundItem.id}&category=1&episode=${episodeData.id}`,
|
||||||
|
{
|
||||||
|
baseURL: netfilmBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = episodeStream.data;
|
||||||
|
|
||||||
|
// get best quality source
|
||||||
|
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
|
||||||
|
.replace("akm-cdn", "aws-cdn")
|
||||||
|
.replace("gg-cdn", "aws-cdn"),
|
||||||
|
quality: qualityMap[source.quality],
|
||||||
|
type: MWStreamType.HLS,
|
||||||
|
captions: mappedCaptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
249
src/backend/providers/superstream/index.ts
Normal file
249
src/backend/providers/superstream/index.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { registerProvider } from "@/backend/helpers/register";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
|
||||||
|
import { customAlphabet } from "nanoid";
|
||||||
|
import CryptoJS from "crypto-js";
|
||||||
|
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
|
import {
|
||||||
|
MWCaption,
|
||||||
|
MWCaptionType,
|
||||||
|
MWStreamQuality,
|
||||||
|
MWStreamType,
|
||||||
|
} from "@/backend/helpers/streams";
|
||||||
|
import { compareTitle } from "@/utils/titleMatch";
|
||||||
|
|
||||||
|
const nanoid = customAlphabet("0123456789abcdef", 32);
|
||||||
|
|
||||||
|
const qualityMap = {
|
||||||
|
"360p": MWStreamQuality.Q360P,
|
||||||
|
"480p": MWStreamQuality.Q480P,
|
||||||
|
"720p": MWStreamQuality.Q720P,
|
||||||
|
"1080p": MWStreamQuality.Q1080P,
|
||||||
|
};
|
||||||
|
type QualityInMap = keyof typeof qualityMap;
|
||||||
|
|
||||||
|
// CONSTANTS, read below (taken from og)
|
||||||
|
// We do not want content scanners to notice this scraping going on so we've hidden all constants
|
||||||
|
// The source has its origins in China so I added some extra security with banned words
|
||||||
|
// Mayhaps a tiny bit unethical, but this source is just too good :)
|
||||||
|
// If you are copying this code please use precautions so they do not change their api.
|
||||||
|
const iv = atob("d0VpcGhUbiE=");
|
||||||
|
const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2");
|
||||||
|
const apiUrls = [
|
||||||
|
atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="),
|
||||||
|
atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="),
|
||||||
|
];
|
||||||
|
const appKey = atob("bW92aWVib3g=");
|
||||||
|
const appId = atob("Y29tLnRkby5zaG93Ym94");
|
||||||
|
|
||||||
|
// cryptography stuff
|
||||||
|
const crypto = {
|
||||||
|
encrypt(str: string) {
|
||||||
|
return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), {
|
||||||
|
iv: CryptoJS.enc.Utf8.parse(iv),
|
||||||
|
}).toString();
|
||||||
|
},
|
||||||
|
getVerify(str: string, str2: string, str3: string) {
|
||||||
|
if (str) {
|
||||||
|
return CryptoJS.MD5(
|
||||||
|
CryptoJS.MD5(str2).toString() + str3 + str
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// get expire time
|
||||||
|
const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12);
|
||||||
|
|
||||||
|
// sending requests
|
||||||
|
const get = (data: object, altApi = false) => {
|
||||||
|
const defaultData = {
|
||||||
|
childmode: "0",
|
||||||
|
app_version: "11.5",
|
||||||
|
appid: appId,
|
||||||
|
lang: "en",
|
||||||
|
expired_date: `${expiry()}`,
|
||||||
|
platform: "android",
|
||||||
|
channel: "Website",
|
||||||
|
};
|
||||||
|
const encryptedData = crypto.encrypt(
|
||||||
|
JSON.stringify({
|
||||||
|
...defaultData,
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const appKeyHash = CryptoJS.MD5(appKey).toString();
|
||||||
|
const verify = crypto.getVerify(encryptedData, appKey, key);
|
||||||
|
const body = JSON.stringify({
|
||||||
|
app_key: appKeyHash,
|
||||||
|
verify,
|
||||||
|
encrypt_data: encryptedData,
|
||||||
|
});
|
||||||
|
const b64Body = btoa(body);
|
||||||
|
|
||||||
|
const formatted = new URLSearchParams();
|
||||||
|
formatted.append("data", b64Body);
|
||||||
|
formatted.append("appid", "27");
|
||||||
|
formatted.append("platform", "android");
|
||||||
|
formatted.append("version", "129");
|
||||||
|
formatted.append("medium", "Website");
|
||||||
|
|
||||||
|
const requestUrl = altApi ? apiUrls[1] : apiUrls[0];
|
||||||
|
return proxiedFetch<any>(requestUrl, {
|
||||||
|
method: "POST",
|
||||||
|
parseResponse: JSON.parse,
|
||||||
|
headers: {
|
||||||
|
Platform: "android",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: `${formatted.toString()}&token${nanoid()}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find best resolution
|
||||||
|
const getBestQuality = (list: any[]) => {
|
||||||
|
return (
|
||||||
|
list.find((quality: any) => quality.quality === "1080p" && quality.path) ??
|
||||||
|
list.find((quality: any) => quality.quality === "720p" && quality.path) ??
|
||||||
|
list.find((quality: any) => quality.quality === "480p" && quality.path) ??
|
||||||
|
list.find((quality: any) => quality.quality === "360p" && quality.path)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerProvider({
|
||||||
|
id: "superstream",
|
||||||
|
displayName: "Superstream",
|
||||||
|
rank: 200,
|
||||||
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
|
|
||||||
|
async scrape({ media, episode, progress }) {
|
||||||
|
// Find Superstream ID for show
|
||||||
|
const searchQuery = {
|
||||||
|
module: "Search3",
|
||||||
|
page: "1",
|
||||||
|
type: "all",
|
||||||
|
keyword: media.meta.title,
|
||||||
|
pagelimit: "20",
|
||||||
|
};
|
||||||
|
const searchRes = (await get(searchQuery, true)).data;
|
||||||
|
progress(33);
|
||||||
|
|
||||||
|
const superstreamEntry = searchRes.find(
|
||||||
|
(res: any) =>
|
||||||
|
compareTitle(res.title, media.meta.title) &&
|
||||||
|
res.year === Number(media.meta.year)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!superstreamEntry) throw new Error("No entry found on SuperStream");
|
||||||
|
const superstreamId = superstreamEntry.id;
|
||||||
|
|
||||||
|
// Movie logic
|
||||||
|
if (media.meta.type === MWMediaType.MOVIE) {
|
||||||
|
const apiQuery = {
|
||||||
|
uid: "",
|
||||||
|
module: "Movie_downloadurl_v3",
|
||||||
|
mid: superstreamId,
|
||||||
|
oss: "1",
|
||||||
|
group: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaRes = (await get(apiQuery)).data;
|
||||||
|
progress(50);
|
||||||
|
|
||||||
|
const hdQuality = getBestQuality(mediaRes.list);
|
||||||
|
|
||||||
|
if (!hdQuality) throw new Error("No quality could be found.");
|
||||||
|
|
||||||
|
const subtitleApiQuery = {
|
||||||
|
fid: hdQuality.fid,
|
||||||
|
uid: "",
|
||||||
|
module: "Movie_srt_list_v2",
|
||||||
|
mid: superstreamId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||||
|
|
||||||
|
const mappedCaptions = subtitleRes.list.map(
|
||||||
|
(subtitle: any): MWCaption => {
|
||||||
|
return {
|
||||||
|
needsProxy: true,
|
||||||
|
langIso: subtitle.language,
|
||||||
|
url: subtitle.subtitles[0].file_path,
|
||||||
|
type: MWCaptionType.SRT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeds: [],
|
||||||
|
stream: {
|
||||||
|
streamUrl: hdQuality.path,
|
||||||
|
quality: qualityMap[hdQuality.quality as QualityInMap],
|
||||||
|
type: MWStreamType.MP4,
|
||||||
|
captions: mappedCaptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.meta.type !== MWMediaType.SERIES)
|
||||||
|
throw new Error("Unsupported type");
|
||||||
|
|
||||||
|
// Fetch requested episode
|
||||||
|
const apiQuery = {
|
||||||
|
uid: "",
|
||||||
|
module: "TV_downloadurl_v3",
|
||||||
|
tid: superstreamId,
|
||||||
|
season: media.meta.seasonData.number.toString(),
|
||||||
|
episode: (
|
||||||
|
media.meta.seasonData.episodes.find(
|
||||||
|
(episodeInfo) => episodeInfo.id === episode
|
||||||
|
)?.number ?? 1
|
||||||
|
).toString(),
|
||||||
|
oss: "1",
|
||||||
|
group: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaRes = (await get(apiQuery)).data;
|
||||||
|
progress(66);
|
||||||
|
|
||||||
|
const hdQuality = getBestQuality(mediaRes.list);
|
||||||
|
|
||||||
|
if (!hdQuality) throw new Error("No quality could be found.");
|
||||||
|
|
||||||
|
const subtitleApiQuery = {
|
||||||
|
fid: hdQuality.fid,
|
||||||
|
uid: "",
|
||||||
|
module: "TV_srt_list_v2",
|
||||||
|
episode:
|
||||||
|
media.meta.seasonData.episodes.find(
|
||||||
|
(episodeInfo) => episodeInfo.id === episode
|
||||||
|
)?.number ?? 1,
|
||||||
|
tid: superstreamId,
|
||||||
|
season: media.meta.seasonData.number.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||||
|
|
||||||
|
const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => {
|
||||||
|
return {
|
||||||
|
needsProxy: true,
|
||||||
|
langIso: subtitle.language,
|
||||||
|
url: subtitle.subtitles[0].file_path,
|
||||||
|
type: MWCaptionType.SRT,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeds: [],
|
||||||
|
stream: {
|
||||||
|
quality: qualityMap[
|
||||||
|
hdQuality.quality as QualityInMap
|
||||||
|
] as MWStreamQuality,
|
||||||
|
streamUrl: hdQuality.path,
|
||||||
|
type: MWStreamType.MP4,
|
||||||
|
captions: mappedCaptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
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>
|
||||||
|
);
|
||||||
|
}
|
25
src/components/Button.tsx
Normal file
25
src/components/Button.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon?: Icons;
|
||||||
|
onClick?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button(props: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onClick}
|
||||||
|
className="inline-flex items-center justify-center rounded-lg bg-white px-8 py-3 font-bold text-black transition-[transform,background-color] duration-100 hover:bg-gray-200 active:scale-105 md:px-16"
|
||||||
|
>
|
||||||
|
{props.icon ? (
|
||||||
|
<span className="mr-3 hidden md:inline-block">
|
||||||
|
<Icon icon={props.icon} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
@@ -57,5 +57,5 @@ export function Dropdown(props: DropdownProps) {
|
|||||||
)}
|
)}
|
||||||
</Listbox>
|
</Listbox>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,16 @@
|
|||||||
|
import { memo, useEffect, useRef } from "react";
|
||||||
|
|
||||||
export enum Icons {
|
export enum Icons {
|
||||||
SEARCH = "search",
|
SEARCH = "search",
|
||||||
BOOKMARK = "bookmark",
|
BOOKMARK = "bookmark",
|
||||||
|
BOOKMARK_OUTLINE = "bookmark_outline",
|
||||||
CLOCK = "clock",
|
CLOCK = "clock",
|
||||||
EYE_SLASH = "eyeSlash",
|
EYE_SLASH = "eyeSlash",
|
||||||
ARROW_LEFT = "arrowLeft",
|
ARROW_LEFT = "arrowLeft",
|
||||||
ARROW_RIGHT = "arrowRight",
|
ARROW_RIGHT = "arrowRight",
|
||||||
CHEVRON_DOWN = "chevronDown",
|
CHEVRON_DOWN = "chevronDown",
|
||||||
CHEVRON_RIGHT = "chevronRight",
|
CHEVRON_RIGHT = "chevronRight",
|
||||||
|
CHEVRON_LEFT = "chevronLeft",
|
||||||
CLAPPER_BOARD = "clapperBoard",
|
CLAPPER_BOARD = "clapperBoard",
|
||||||
FILM = "film",
|
FILM = "film",
|
||||||
DRAGON = "dragon",
|
DRAGON = "dragon",
|
||||||
@@ -14,6 +18,28 @@ export enum Icons {
|
|||||||
MOVIE_WEB = "movieWeb",
|
MOVIE_WEB = "movieWeb",
|
||||||
DISCORD = "discord",
|
DISCORD = "discord",
|
||||||
GITHUB = "github",
|
GITHUB = "github",
|
||||||
|
PLAY = "play",
|
||||||
|
PAUSE = "pause",
|
||||||
|
EXPAND = "expand",
|
||||||
|
COMPRESS = "compress",
|
||||||
|
VOLUME = "volume",
|
||||||
|
VOLUME_X = "volume_x",
|
||||||
|
X = "x",
|
||||||
|
EDIT = "edit",
|
||||||
|
AIRPLAY = "airplay",
|
||||||
|
EPISODES = "episodes",
|
||||||
|
SKIP_FORWARD = "skip_forward",
|
||||||
|
SKIP_BACKWARD = "skip_backward",
|
||||||
|
FILE = "file",
|
||||||
|
CAPTIONS = "captions",
|
||||||
|
LINK = "link",
|
||||||
|
CASTING = "casting",
|
||||||
|
CIRCLE_EXCLAMATION = "circle_exclamation",
|
||||||
|
DOWNLOAD = "download",
|
||||||
|
GEAR = "gear",
|
||||||
|
WATCH_PARTY = "watch_party",
|
||||||
|
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||||
|
CHECKMARK = "checkmark",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
@@ -29,6 +55,7 @@ const iconList: Record<Icons, string> = {
|
|||||||
arrowLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>`,
|
arrowLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>`,
|
||||||
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
|
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
|
||||||
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
|
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
|
||||||
|
chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-left"><polyline points="15 18 9 12 15 6"></polyline></svg>`,
|
||||||
clapperBoard: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`,
|
clapperBoard: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`,
|
||||||
film: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M463.1 32h-416C21.49 32-.0001 53.49-.0001 80v352c0 26.51 21.49 48 47.1 48h416c26.51 0 48-21.49 48-48v-352C511.1 53.49 490.5 32 463.1 32zM111.1 408c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 408zM111.1 280c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM111.1 152c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 152zM351.1 400c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V400zM351.1 208c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V208zM463.1 408c0 4.418-3.582 8-8 8h-47.1c-4.418 0-7.1-3.582-7.1-8l0-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V408zM463.1 280c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM463.1 152c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8l0-48c0-4.418 3.582-8 7.1-8h47.1c4.418 0 8 3.582 8 8V152z"/></svg>`,
|
film: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M463.1 32h-416C21.49 32-.0001 53.49-.0001 80v352c0 26.51 21.49 48 47.1 48h416c26.51 0 48-21.49 48-48v-352C511.1 53.49 490.5 32 463.1 32zM111.1 408c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 408zM111.1 280c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM111.1 152c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 152zM351.1 400c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V400zM351.1 208c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V208zM463.1 408c0 4.418-3.582 8-8 8h-47.1c-4.418 0-7.1-3.582-7.1-8l0-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V408zM463.1 280c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM463.1 152c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8l0-48c0-4.418 3.582-8 7.1-8h47.1c4.418 0 8 3.582 8 8V152z"/></svg>`,
|
||||||
dragon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M18.43 255.8L192 224L100.8 292.6C90.67 302.8 97.8 320 112 320h222.7c-9.499-26.5-14.75-54.5-14.75-83.38V194.2L200.3 106.8C176.5 90.88 145 92.75 123.3 111.2l-117.5 116.4C-6.562 238 2.436 258 18.43 255.8zM575.2 289.9l-100.7-50.25c-16.25-8.125-26.5-24.75-26.5-43V160h63.99l28.12 22.62C546.1 188.6 554.2 192 562.7 192h30.1c11.1 0 23.12-6.875 28.5-17.75l14.37-28.62c5.374-10.87 4.25-23.75-2.999-33.5l-74.49-99.37C552.1 4.75 543.5 0 533.5 0H296C288.9 0 285.4 8.625 290.4 13.62L351.1 64L292.4 88.75c-5.874 3-5.874 11.37 0 14.37L351.1 128l-.0011 108.6c0 72 35.99 139.4 95.99 179.4c-195.6 6.75-344.4 41-434.1 60.88c-8.124 1.75-13.87 9-13.87 17.38C.0463 504 8.045 512 17.79 512h499.1c63.24 0 119.6-47.5 122.1-110.8C642.3 354 617.1 310.9 575.2 289.9zM489.1 66.25l45.74 11.38c-2.75 11-12.5 18.88-24.12 18.25C497.7 95.25 484.8 83.38 489.1 66.25z"/></svg>`,
|
dragon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M18.43 255.8L192 224L100.8 292.6C90.67 302.8 97.8 320 112 320h222.7c-9.499-26.5-14.75-54.5-14.75-83.38V194.2L200.3 106.8C176.5 90.88 145 92.75 123.3 111.2l-117.5 116.4C-6.562 238 2.436 258 18.43 255.8zM575.2 289.9l-100.7-50.25c-16.25-8.125-26.5-24.75-26.5-43V160h63.99l28.12 22.62C546.1 188.6 554.2 192 562.7 192h30.1c11.1 0 23.12-6.875 28.5-17.75l14.37-28.62c5.374-10.87 4.25-23.75-2.999-33.5l-74.49-99.37C552.1 4.75 543.5 0 533.5 0H296C288.9 0 285.4 8.625 290.4 13.62L351.1 64L292.4 88.75c-5.874 3-5.874 11.37 0 14.37L351.1 128l-.0011 108.6c0 72 35.99 139.4 95.99 179.4c-195.6 6.75-344.4 41-434.1 60.88c-8.124 1.75-13.87 9-13.87 17.38C.0463 504 8.045 512 17.79 512h499.1c63.24 0 119.6-47.5 122.1-110.8C642.3 354 617.1 310.9 575.2 289.9zM489.1 66.25l45.74 11.38c-2.75 11-12.5 18.88-24.12 18.25C497.7 95.25 484.8 83.38 489.1 66.25z"/></svg>`,
|
||||||
@@ -37,13 +64,52 @@ const iconList: Record<Icons, string> = {
|
|||||||
movieWeb: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20.927 20.927"><path d="M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z" transform="translate(10.018 -7.425) rotate(45)" fill="currentColor"/></svg>`,
|
movieWeb: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20.927 20.927"><path d="M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z" transform="translate(10.018 -7.425) rotate(45)" fill="currentColor"/></svg>`,
|
||||||
discord: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`,
|
discord: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`,
|
||||||
github: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 496 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>`,
|
github: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 496 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>`,
|
||||||
|
play: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" style="transform: translateX(5%)" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>`,
|
||||||
|
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M48 64C21.5 64 0 85.5 0 112V400c0 26.5 21.5 48 48 48H80c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zm192 0c-26.5 0-48 21.5-48 48V400c0 26.5 21.5 48 48 48h32c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H240z"/></svg>`,
|
||||||
|
expand: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64v96c0 17.7 14.3 32 32 32s32-14.3 32-32V96h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H64V352zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64v64c0 17.7 14.3 32 32 32s32-14.3 32-32V64c0-17.7-14.3-32-32-32H320zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H320c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V352z"/></svg>`,
|
||||||
|
compress: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M160 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V64zM32 320c-17.7 0-32 14.3-32 32s14.3 32 32 32H96v64c0 17.7 14.3 32 32 32s32-14.3 32-32V352c0-17.7-14.3-32-32-32H32zM352 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H352V64zM320 320c-17.7 0-32 14.3-32 32v96c0 17.7 14.3 32 32 32s32-14.3 32-32V384h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H320z"/></svg>`,
|
||||||
|
volume: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>`,
|
||||||
|
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
|
||||||
|
x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"/></svg>`,
|
||||||
|
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>`,
|
||||||
|
bookmark_outline: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M336 0h-288C21.49 0 0 21.49 0 48v431.9c0 24.7 26.79 40.08 48.12 27.64L192 423.6l143.9 83.93C357.2 519.1 384 504.6 384 479.9V48C384 21.49 362.5 0 336 0zM336 452L192 368l-144 84V54C48 50.63 50.63 48 53.1 48h276C333.4 48 336 50.63 336 54V452z"/></svg>`,
|
||||||
|
airplay: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-airplay"><path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"></path><polygon fill="currentColor" points="12 15 17 21 7 21 12 15"></polygon></svg>`,
|
||||||
|
episodes: `<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4C1.34315 4 0 5.34314 0 7V13.9496C0 15.6065 1.34315 16.9496 3 16.9496H5.86645V14.9496H3C2.44772 14.9496 2 14.5019 2 13.9496V7C2 6.44771 2.44771 6 3 6H16.0327C16.585 6 17.0327 6.44772 17.0327 7V9.86645H19.0327V7C19.0327 5.34315 17.6896 4 16.0327 4H3Z" fill="currentColor"/><rect x="5.89929" y="10.5444" width="17" height="10" rx="2" stroke="currentColor" stroke-width="2"/></svg>`,
|
||||||
|
skip_forward: `<svg width="1em" height="1em" viewBox="0 0 26 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.3333 12.3333L16 7.66667M16 7.66667L11.3333 3M16 7.66667H6.66667C5.42899 7.66667 4.242 8.15833 3.36684 9.0335C2.49167 9.90867 2 11.0957 2 12.3333C2 13.571 2.49167 14.758 3.36684 15.6332C4.242 16.5083 5.42899 17 6.66667 17H9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16.5043 14.2727V23H14.6591V16.0241H14.608L12.6094 17.277V15.6406L14.7699 14.2727H16.5043ZM22.0004 23.1918C21.2674 23.1889 20.6367 23.0085 20.1083 22.6506C19.5827 22.2926 19.1779 21.7741 18.8938 21.0952C18.6126 20.4162 18.4734 19.5994 18.4762 18.6449C18.4762 17.6932 18.6168 16.8821 18.8981 16.2116C19.1822 15.5412 19.587 15.0312 20.1126 14.6818C20.641 14.3295 21.2702 14.1534 22.0004 14.1534C22.7305 14.1534 23.3583 14.3295 23.8839 14.6818C24.4123 15.0341 24.8185 15.5455 25.1026 16.2159C25.3867 16.8835 25.5273 17.6932 25.5245 18.6449C25.5245 19.6023 25.3825 20.4205 25.0984 21.0994C24.8171 21.7784 24.4137 22.2969 23.8881 22.6548C23.3626 23.0128 22.7333 23.1918 22.0004 23.1918ZM22.0004 21.6619C22.5004 21.6619 22.8995 21.4105 23.1978 20.9077C23.4961 20.4048 23.6438 19.6506 23.641 18.6449C23.641 17.983 23.5728 17.4318 23.4364 16.9915C23.3029 16.5511 23.1126 16.2202 22.8654 15.9986C22.6211 15.777 22.3327 15.6662 22.0004 15.6662C21.5032 15.6662 21.1055 15.9148 20.8072 16.4119C20.5089 16.9091 20.3583 17.6534 20.3555 18.6449C20.3555 19.3153 20.4222 19.875 20.5558 20.3239C20.6921 20.7699 20.8839 21.1051 21.131 21.3295C21.3782 21.5511 21.668 21.6619 22.0004 21.6619Z" fill="currentColor" /></svg>`,
|
||||||
|
skip_backward: `<svg width="1em" height="1em" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`,
|
||||||
|
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
||||||
|
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 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>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Icon(props: IconProps) {
|
function ChromeCastButton() {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tag = document.createElement("google-cast-launcher");
|
||||||
|
tag.setAttribute("id", "castbutton");
|
||||||
|
ref.current?.appendChild(tag);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div ref={ref} className="h-6" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Icon = memo((props: IconProps) => {
|
||||||
|
if (props.icon === Icons.CASTING) {
|
||||||
|
return <ChromeCastButton />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
|
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
|
||||||
className={props.className}
|
className={props.className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
20
src/components/Overlay.tsx
Normal file
20
src/components/Overlay.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Transition } from "@/components/Transition";
|
||||||
|
import { Helmet } from "react-helmet";
|
||||||
|
|
||||||
|
export function Overlay(props: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<body data-no-scroll />
|
||||||
|
</Helmet>
|
||||||
|
<div className="fixed inset-0 z-[99999]">
|
||||||
|
<Transition
|
||||||
|
animation="fade"
|
||||||
|
className="absolute inset-0 bg-[rgba(8,6,18,0.85)]"
|
||||||
|
isChild
|
||||||
|
/>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,8 +1,8 @@
|
|||||||
|
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MWMediaType, MWQuery } from "@/providers";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DropdownButton } from "./buttons/DropdownButton";
|
import { DropdownButton } from "./buttons/DropdownButton";
|
||||||
import { Icons } from "./Icon";
|
import { Icon, Icons } from "./Icon";
|
||||||
import { TextInputControl } from "./text-inputs/TextInputControl";
|
import { TextInputControl } from "./text-inputs/TextInputControl";
|
||||||
|
|
||||||
export interface SearchBarProps {
|
export interface SearchBarProps {
|
||||||
@@ -37,15 +37,20 @@ export function SearchBarInput(props: SearchBarProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-[28px] bg-denim-300 px-4 py-4 transition-colors focus-within:bg-denim-400 hover:bg-denim-400 sm:flex-row sm:py-2 sm:pl-8 sm:pr-2">
|
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
|
||||||
|
<div className="pointer-events-none absolute left-5 top-0 bottom-0 flex max-h-14 items-center">
|
||||||
|
<Icon icon={Icons.SEARCH} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<TextInputControl
|
<TextInputControl
|
||||||
onUnFocus={props.onUnFocus}
|
onUnFocus={props.onUnFocus}
|
||||||
onChange={(val) => setSearch(val)}
|
onChange={(val) => setSearch(val)}
|
||||||
value={props.value.searchQuery}
|
value={props.value.searchQuery}
|
||||||
className="w-full flex-1 bg-transparent text-white placeholder-denim-700 focus:outline-none"
|
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2"
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="px-4 py-4 pt-0 sm:py-2 sm:px-2">
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
icon={Icons.SEARCH}
|
icon={Icons.SEARCH}
|
||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
@@ -55,24 +60,20 @@ export function SearchBarInput(props: SearchBarProps) {
|
|||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
id: MWMediaType.MOVIE,
|
id: MWMediaType.MOVIE,
|
||||||
name: t('searchBar.movie'),
|
name: t("searchBar.movie"),
|
||||||
icon: Icons.FILM,
|
icon: Icons.FILM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: MWMediaType.SERIES,
|
id: MWMediaType.SERIES,
|
||||||
name: t('searchBar.series'),
|
name: t("searchBar.series"),
|
||||||
icon: Icons.CLAPPER_BOARD,
|
icon: Icons.CLAPPER_BOARD,
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// id: MWMediaType.ANIME,
|
|
||||||
// name: "Anime",
|
|
||||||
// icon: Icons.DRAGON,
|
|
||||||
// },
|
|
||||||
]}
|
]}
|
||||||
onClick={() => setDropdownOpen((old) => !old)}
|
onClick={() => setDropdownOpen((old) => !old)}
|
||||||
>
|
>
|
||||||
{props.buttonText || t('searchBar.search')}
|
{props.buttonText || t("searchBar.search")}
|
||||||
</DropdownButton>
|
</DropdownButton>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
103
src/components/Transition.tsx
Normal file
103
src/components/Transition.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Fragment, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Transition as HeadlessTransition,
|
||||||
|
TransitionClasses,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
|
||||||
|
type TransitionAnimations =
|
||||||
|
| "slide-down"
|
||||||
|
| "slide-full-left"
|
||||||
|
| "slide-full-right"
|
||||||
|
| "slide-up"
|
||||||
|
| "fade"
|
||||||
|
| "none";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show?: boolean;
|
||||||
|
durationClass?: string;
|
||||||
|
animation: TransitionAnimations;
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
isChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClasses(
|
||||||
|
animation: TransitionAnimations,
|
||||||
|
duration: string
|
||||||
|
): TransitionClasses {
|
||||||
|
if (animation === "slide-down") {
|
||||||
|
return {
|
||||||
|
leave: `transition-[transform,opacity] ${duration}`,
|
||||||
|
leaveFrom: "opacity-100 translate-y-0",
|
||||||
|
leaveTo: "-translate-y-4 opacity-0",
|
||||||
|
enter: `transition-[transform,opacity] ${duration}`,
|
||||||
|
enterFrom: "opacity-0 -translate-y-4",
|
||||||
|
enterTo: "translate-y-0 opacity-100",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animation === "slide-up") {
|
||||||
|
return {
|
||||||
|
leave: `transition-[transform,opacity] ${duration}`,
|
||||||
|
leaveFrom: "opacity-100 translate-y-0",
|
||||||
|
leaveTo: "translate-y-4 opacity-0",
|
||||||
|
enter: `transition-[transform,opacity] ${duration}`,
|
||||||
|
enterFrom: "opacity-0 translate-y-4",
|
||||||
|
enterTo: "translate-y-0 opacity-100",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animation === "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}`,
|
||||||
|
leaveFrom: "opacity-100",
|
||||||
|
leaveTo: "opacity-0",
|
||||||
|
enter: `transition-[transform,opacity] ${duration}`,
|
||||||
|
enterFrom: "opacity-0",
|
||||||
|
enterTo: "opacity-100",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Transition(props: Props) {
|
||||||
|
const duration = props.durationClass ?? "duration-200";
|
||||||
|
const classes = getClasses(props.animation, duration);
|
||||||
|
|
||||||
|
if (props.isChild) {
|
||||||
|
return (
|
||||||
|
<HeadlessTransition.Child as={Fragment} {...classes}>
|
||||||
|
<div className={props.className}>{props.children}</div>
|
||||||
|
</HeadlessTransition.Child>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeadlessTransition show={props.show} as={Fragment} {...classes}>
|
||||||
|
<div className={props.className}>{props.children}</div>
|
||||||
|
</HeadlessTransition>
|
||||||
|
);
|
||||||
|
}
|
@@ -6,7 +6,7 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
import { Backdrop, useBackdrop } from "@/components/layout/Backdrop";
|
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
|
||||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||||
|
|
||||||
export interface OptionItem {
|
export interface OptionItem {
|
||||||
@@ -56,7 +56,7 @@ export const DropdownButton = React.forwardRef<
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let id: NodeJS.Timeout;
|
let id: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
if (props.open) {
|
if (props.open) {
|
||||||
setDelayedSelectedId(props.selectedItem);
|
setDelayedSelectedId(props.selectedItem);
|
||||||
@@ -92,16 +92,22 @@ export const DropdownButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className="relative w-full sm:w-auto"
|
className="relative w-full sm:w-auto"
|
||||||
{...highlightedProps}
|
{...highlightedProps}
|
||||||
|
>
|
||||||
|
<BackdropContainer
|
||||||
|
onClick={() => props.setOpen(false)}
|
||||||
|
{...backdropProps}
|
||||||
>
|
>
|
||||||
<ButtonControl
|
<ButtonControl
|
||||||
{...props}
|
{...props}
|
||||||
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-200 px-4 py-2 text-white hover:bg-bink-300"
|
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-400 px-4 py-2 text-white hover:bg-bink-300"
|
||||||
>
|
>
|
||||||
<Icon icon={selectedItem.icon} />
|
<Icon icon={selectedItem.icon} />
|
||||||
<span className="flex-1">{selectedItem.name}</span>
|
<span className="flex-1">{selectedItem.name}</span>
|
||||||
<Icon
|
<Icon
|
||||||
icon={Icons.CHEVRON_DOWN}
|
icon={Icons.CHEVRON_DOWN}
|
||||||
className={`transition-transform ${props.open ? "rotate-180" : ""}`}
|
className={`transition-transform ${
|
||||||
|
props.open ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</ButtonControl>
|
</ButtonControl>
|
||||||
<div
|
<div
|
||||||
@@ -122,8 +128,8 @@ export const DropdownButton = React.forwardRef<
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</BackdropContainer>
|
||||||
</div>
|
</div>
|
||||||
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
36
src/components/buttons/EditButton.tsx
Normal file
36
src/components/buttons/EditButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ButtonControl } from "./ButtonControl";
|
||||||
|
|
||||||
|
export interface EditButtonProps {
|
||||||
|
editing: boolean;
|
||||||
|
onEdit?: (editing: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditButton(props: EditButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [parent] = useAutoAnimate<HTMLSpanElement>();
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
props.onEdit?.(!props.editing);
|
||||||
|
}, [props]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonControl
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex h-12 items-center overflow-hidden rounded-full bg-denim-400 px-4 py-2 text-white transition-[background-color,transform] hover:bg-denim-500 active:scale-105"
|
||||||
|
>
|
||||||
|
<span ref={parent}>
|
||||||
|
{props.editing ? (
|
||||||
|
<span className="mx-4 whitespace-nowrap">
|
||||||
|
{t("media.stopEditing")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Icon icon={Icons.EDIT} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</ButtonControl>
|
||||||
|
);
|
||||||
|
}
|
@@ -6,17 +6,24 @@ export interface IconPatchProps {
|
|||||||
clickable?: boolean;
|
clickable?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
icon: Icons;
|
icon: Icons;
|
||||||
|
transparent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IconPatch(props: IconPatchProps) {
|
export function IconPatch(props: IconPatchProps) {
|
||||||
|
const clickableClasses = props.clickable
|
||||||
|
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
|
||||||
|
: "";
|
||||||
|
const transparentClasses = props.transparent
|
||||||
|
? "bg-opacity-0 hover:bg-opacity-50"
|
||||||
|
: "";
|
||||||
|
const activeClasses = props.active
|
||||||
|
? "border-bink-600 bg-bink-100 text-bink-600"
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={props.className || undefined} onClick={props.onClick}>
|
<div className={props.className || undefined} onClick={props.onClick}>
|
||||||
<div
|
<div
|
||||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-300 transition-[color,transform,border-color] duration-75 ${
|
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-500 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses}`}
|
||||||
props.clickable
|
|
||||||
? "cursor-pointer hover:scale-110 hover:bg-denim-400 hover:text-white active:scale-125"
|
|
||||||
: ""
|
|
||||||
} ${props.active ? "border-bink-600 bg-bink-100 text-bink-600" : ""}`}
|
|
||||||
>
|
>
|
||||||
<Icon icon={props.icon} />
|
<Icon icon={props.icon} />
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import React, { createRef, useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useFade } from "@/hooks/useFade";
|
import { useFade } from "@/hooks/useFade";
|
||||||
|
|
||||||
interface BackdropProps {
|
interface BackdropProps {
|
||||||
@@ -39,7 +40,7 @@ export function useBackdrop(): [
|
|||||||
return [setBackdrop, backdropProps, highlightedProps];
|
return [setBackdrop, backdropProps, highlightedProps];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Backdrop(props: BackdropProps) {
|
function Backdrop(props: BackdropProps) {
|
||||||
const clickEvent = props.onClick || (() => {});
|
const clickEvent = props.onClick || (() => {});
|
||||||
const animationEvent = props.onBackdropHide || (() => {});
|
const animationEvent = props.onBackdropHide || (() => {});
|
||||||
const [isVisible, setVisible, fadeProps] = useFade();
|
const [isVisible, setVisible, fadeProps] = useFade();
|
||||||
@@ -58,7 +59,7 @@ export function Backdrop(props: BackdropProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 left-0 right-0 z-[999] h-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
className={`pointer-events-auto fixed left-0 right-0 top-0 h-screen w-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||||
!isVisible ? "opacity-0" : ""
|
!isVisible ? "opacity-0" : ""
|
||||||
}`}
|
}`}
|
||||||
{...fadeProps}
|
{...fadeProps}
|
||||||
@@ -66,3 +67,47 @@ export function Backdrop(props: BackdropProps) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BackdropContainer(
|
||||||
|
props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
} & BackdropProps
|
||||||
|
) {
|
||||||
|
const root = createRef<HTMLDivElement>();
|
||||||
|
const copy = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let frame = -1;
|
||||||
|
function poll() {
|
||||||
|
if (root.current && copy.current) {
|
||||||
|
const rect = root.current.getBoundingClientRect();
|
||||||
|
copy.current.style.top = `${rect.top}px`;
|
||||||
|
copy.current.style.left = `${rect.left}px`;
|
||||||
|
copy.current.style.width = `${rect.width}px`;
|
||||||
|
copy.current.style.height = `${rect.height}px`;
|
||||||
|
}
|
||||||
|
frame = window.requestAnimationFrame(poll);
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
// we dont want this to run only on mount, dont care about ref updates
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [root, copy]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={root}>
|
||||||
|
{createPortal(
|
||||||
|
<div className="pointer-events-none fixed top-0 left-0 z-[999]">
|
||||||
|
<Backdrop active={props.active} {...props} />
|
||||||
|
<div ref={copy} className="pointer-events-auto absolute">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
<div className="invisible">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,18 +1,29 @@
|
|||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { useTranslation } from "react-i18next";
|
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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center space-x-2 rounded-full bg-bink-100 bg-opacity-50 px-4 py-2 text-bink-600 ${props.clickable
|
className={`flex items-center space-x-2 rounded-full bg-bink-300 bg-opacity-50 px-4 py-2 text-bink-600 ${
|
||||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-200 hover:text-bink-700 active:scale-95"
|
props.clickable
|
||||||
|
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-400 hover:text-bink-700 active:scale-95"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
||||||
<span className="font-semibold text-white">{t('global.name')}</span>
|
<span
|
||||||
|
className={[
|
||||||
|
"font-semibold text-white",
|
||||||
|
props.hideTextOnMobile ? "hidden sm:block" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{t("global.name")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -3,7 +3,65 @@ import { IconPatch } from "@/components/buttons/IconPatch";
|
|||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { Link } from "@/components/text/Link";
|
import { Link } from "@/components/text/Link";
|
||||||
import { Title } from "@/components/text/Title";
|
import { Title } from "@/components/text/Title";
|
||||||
import { DISCORD_LINK, GITHUB_LINK } from "@/mw_constants";
|
import { conf } from "@/setup/config";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface ErrorShowcaseProps {
|
||||||
|
error: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorShowcase(props: ErrorShowcaseProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
|
||||||
|
<p className="mb-1 break-words font-bold text-white">
|
||||||
|
{props.error.name} - {props.error.description}
|
||||||
|
</p>
|
||||||
|
<p className="break-words">{props.error.path}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorMessageProps {
|
||||||
|
error?: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
localSize?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorMessage(props: ErrorMessageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
props.localSize ? "h-full" : "min-h-screen"
|
||||||
|
} flex w-full flex-col items-center justify-center px-4 py-12`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-start text-center">
|
||||||
|
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
||||||
|
<Title>{t("media.errors.genericTitle")}</Title>
|
||||||
|
{props.children ? (
|
||||||
|
<p className="my-6 max-w-lg">{props.children}</p>
|
||||||
|
) : (
|
||||||
|
<p className="my-6 max-w-lg">
|
||||||
|
<Trans i18nKey="media.errors.videoFailed">
|
||||||
|
<Link url={conf().DISCORD_LINK} newTab />
|
||||||
|
<Link url={conf().GITHUB_LINK} newTab />
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{props.error ? <ErrorShowcase error={props.error} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
@@ -50,33 +108,6 @@ export class ErrorBoundary extends Component<
|
|||||||
render() {
|
render() {
|
||||||
if (!this.state.hasError) return this.props.children as any;
|
if (!this.state.hasError) return this.props.children as any;
|
||||||
|
|
||||||
return (
|
return <ErrorMessage error={this.state.error} />;
|
||||||
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12">
|
|
||||||
<div className="flex flex-col items-center justify-start text-center">
|
|
||||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
|
|
||||||
<Title>Whoops, it broke</Title>
|
|
||||||
<p className="my-6 max-w-lg">
|
|
||||||
The app encountered an error and wasn't able to recover, please
|
|
||||||
report it to the{" "}
|
|
||||||
<Link url={DISCORD_LINK} newTab>
|
|
||||||
Discord server
|
|
||||||
</Link>{" "}
|
|
||||||
or on{" "}
|
|
||||||
<Link url={GITHUB_LINK} newTab>
|
|
||||||
GitHub
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{this.state.error ? (
|
|
||||||
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
|
|
||||||
<p className="mb-1 break-words font-bold text-white">
|
|
||||||
{this.state.error.name} - {this.state.error.description}
|
|
||||||
</p>
|
|
||||||
<p className="break-words">{this.state.error.path}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,10 +8,10 @@ export function Loading(props: LoadingProps) {
|
|||||||
<div className={props.className}>
|
<div className={props.className}>
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<div className="flex h-12 items-center justify-center">
|
<div className="flex h-12 items-center justify-center">
|
||||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full" />
|
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300" />
|
||||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:150ms]" />
|
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:150ms]" />
|
||||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:300ms]" />
|
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:300ms]" />
|
||||||
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:450ms]" />
|
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:450ms]" />
|
||||||
</div>
|
</div>
|
||||||
{props.text && props.text.length ? (
|
{props.text && props.text.length ? (
|
||||||
<p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p>
|
<p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p>
|
||||||
|
44
src/components/layout/Modal.tsx
Normal file
44
src/components/layout/Modal.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Overlay } from "@/components/Overlay";
|
||||||
|
import { Transition } from "@/components/Transition";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalFrame(props: Props) {
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
className="fixed inset-0 z-[9999]"
|
||||||
|
animation="none"
|
||||||
|
show={props.show}
|
||||||
|
>
|
||||||
|
<Overlay>
|
||||||
|
<Transition
|
||||||
|
isChild
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
animation="slide-up"
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Transition>
|
||||||
|
</Overlay>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal(props: Props) {
|
||||||
|
return createPortal(
|
||||||
|
<ModalFrame show={props.show}>{props.children}</ModalFrame>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalCard(props: { children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -2,17 +2,34 @@ import { ReactNode } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { DISCORD_LINK, GITHUB_LINK } from "@/mw_constants";
|
import { conf } from "@/setup/config";
|
||||||
|
import { useBannerSize } from "@/hooks/useBanner";
|
||||||
import { BrandPill } from "./BrandPill";
|
import { BrandPill } from "./BrandPill";
|
||||||
|
|
||||||
export interface NavigationProps {
|
export interface NavigationProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
bg?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Navigation(props: NavigationProps) {
|
export function Navigation(props: NavigationProps) {
|
||||||
|
const bannerHeight = useBannerSize();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 right-0 top-0 flex min-h-[88px] items-center justify-between py-5 px-7">
|
<div
|
||||||
<div className="flex w-full items-center justify-center sm:w-fit">
|
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>
|
||||||
|
<div className="relative flex w-full items-center justify-center sm:w-fit">
|
||||||
<div className="mr-auto sm:mr-6">
|
<div className="mr-auto sm:mr-6">
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<BrandPill clickable />
|
<BrandPill clickable />
|
||||||
@@ -23,10 +40,10 @@ export function Navigation(props: NavigationProps) {
|
|||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
props.children ? "hidden sm:flex" : "flex"
|
props.children ? "hidden sm:flex" : "flex"
|
||||||
} flex-row gap-4`}
|
} relative flex-row gap-4`}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={DISCORD_LINK}
|
href={conf().DISCORD_LINK}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-2xl text-white"
|
className="text-2xl text-white"
|
||||||
@@ -34,7 +51,7 @@ export function Navigation(props: NavigationProps) {
|
|||||||
<IconPatch icon={Icons.DISCORD} clickable />
|
<IconPatch icon={Icons.DISCORD} clickable />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={GITHUB_LINK}
|
href={conf().GITHUB_LINK}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-2xl text-white"
|
className="text-2xl text-white"
|
||||||
@@ -43,5 +60,6 @@ export function Navigation(props: NavigationProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,14 +1,16 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export interface PaperProps {
|
export interface PaperProps {
|
||||||
children?: ReactNode,
|
children?: ReactNode;
|
||||||
className?: string,
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Paper(props: PaperProps) {
|
export function Paper(props: PaperProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`bg-denim-200 lg:rounded-xl px-4 sm:px-8 md:px-12 py-6 sm:py-8 md:py-12 ${props.className}`}>
|
<div
|
||||||
|
className={`bg-denim-200 px-4 py-6 sm:px-8 sm:py-8 md:px-12 md:py-12 lg:rounded-xl ${props.className}`}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
39
src/components/layout/ProgressRing.tsx
Normal file
39
src/components/layout/ProgressRing.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
radius?: number;
|
||||||
|
percentage: number;
|
||||||
|
backingRingClassname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressRing(props: Props) {
|
||||||
|
const radius = props.radius ?? 40;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`${props.className ?? ""} -rotate-90`}
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className={`fill-transparent stroke-denim-700 stroke-[15] opacity-25 ${
|
||||||
|
props.backingRingClassname ?? ""
|
||||||
|
}`}
|
||||||
|
r={radius}
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="fill-transparent stroke-current stroke-[15] transition-[stroke-dashoffset] duration-150"
|
||||||
|
r={radius}
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
style={{
|
||||||
|
strokeDasharray: `${2 * Math.PI * radius} ${2 * Math.PI * radius}`,
|
||||||
|
strokeDashoffset: `${
|
||||||
|
2 * Math.PI * radius -
|
||||||
|
(props.percentage / 100) * (2 * Math.PI * radius)
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,124 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useHistory } from "react-router-dom";
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
|
||||||
import { Dropdown, OptionItem } from "@/components/Dropdown";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { WatchedEpisode } from "@/components/media/WatchedEpisodeButton";
|
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
|
||||||
import { serializePortableMedia } from "@/hooks/usePortableMedia";
|
|
||||||
import {
|
|
||||||
convertMediaToPortable,
|
|
||||||
MWMedia,
|
|
||||||
MWMediaSeasons,
|
|
||||||
MWMediaSeason,
|
|
||||||
MWPortableMedia,
|
|
||||||
} from "@/providers";
|
|
||||||
import { getSeasonDataFromMedia } from "@/providers/methods/seasons";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export interface SeasonsProps {
|
|
||||||
media: MWMedia;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadingSeasons(props: { error?: boolean }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-3 mt-5 h-10 w-56 rounded bg-denim-400 opacity-50" />
|
|
||||||
</div>
|
|
||||||
{!props.error ? (
|
|
||||||
<>
|
|
||||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
|
||||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
|
||||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<IconPatch icon={Icons.WARNING} className="text-red-400" />
|
|
||||||
<p>{t('seasons.failed')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Seasons(props: SeasonsProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [searchSeasons, loading, error, success] = useLoading(
|
|
||||||
(portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia)
|
|
||||||
);
|
|
||||||
const history = useHistory();
|
|
||||||
const [seasons, setSeasons] = useState<MWMediaSeasons>({ seasons: [] });
|
|
||||||
const seasonSelected = props.media.seasonId as string;
|
|
||||||
const episodeSelected = props.media.episodeId as string;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const seasonData = await searchSeasons(props.media);
|
|
||||||
setSeasons(seasonData);
|
|
||||||
})();
|
|
||||||
}, [searchSeasons, props.media]);
|
|
||||||
|
|
||||||
function navigateToSeasonAndEpisode(seasonId: string, episodeId: string) {
|
|
||||||
const newMedia: MWMedia = { ...props.media };
|
|
||||||
newMedia.episodeId = episodeId;
|
|
||||||
newMedia.seasonId = seasonId;
|
|
||||||
history.replace(
|
|
||||||
`/media/${newMedia.mediaType}/${serializePortableMedia(
|
|
||||||
convertMediaToPortable(newMedia)
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapSeason = (season: MWMediaSeason) => ({
|
|
||||||
id: season.id,
|
|
||||||
name: season.title || `${t('seasons.season', { season: season.sort })}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const options = seasons.seasons.map(mapSeason);
|
|
||||||
|
|
||||||
const foundSeason = seasons.seasons.find(
|
|
||||||
(season) => season.id === seasonSelected
|
|
||||||
);
|
|
||||||
const selectedItem = foundSeason ? mapSeason(foundSeason) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{loading ? <LoadingSeasons /> : null}
|
|
||||||
{error ? <LoadingSeasons error /> : null}
|
|
||||||
{success && seasons.seasons.length ? (
|
|
||||||
<>
|
|
||||||
<Dropdown
|
|
||||||
selectedItem={selectedItem as OptionItem}
|
|
||||||
options={options}
|
|
||||||
setSelectedItem={(seasonItem) =>
|
|
||||||
navigateToSeasonAndEpisode(
|
|
||||||
seasonItem.id,
|
|
||||||
seasons.seasons.find((s) => s.id === seasonItem.id)?.episodes[0]
|
|
||||||
.id as string
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{seasons.seasons
|
|
||||||
.find((s) => s.id === seasonSelected)
|
|
||||||
?.episodes.map((v) => (
|
|
||||||
<WatchedEpisode
|
|
||||||
key={v.id}
|
|
||||||
media={{
|
|
||||||
...props.media,
|
|
||||||
seriesData: seasons,
|
|
||||||
episodeId: v.id,
|
|
||||||
seasonId: seasonSelected,
|
|
||||||
}}
|
|
||||||
active={v.id === episodeSelected}
|
|
||||||
onClick={() => navigateToSeasonAndEpisode(seasonSelected, v.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,20 +1,17 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
|
||||||
|
|
||||||
interface SectionHeadingProps {
|
interface SectionHeadingProps {
|
||||||
icon?: Icons;
|
icon?: Icons;
|
||||||
title: string;
|
title: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
linkText?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SectionHeading(props: SectionHeadingProps) {
|
export function SectionHeading(props: SectionHeadingProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`mt-12 ${props.className}`}>
|
<div className={props.className}>
|
||||||
<div className="mb-4 flex items-end">
|
<div className="mb-5 flex items-center">
|
||||||
<p className="flex flex-1 items-center font-bold uppercase text-denim-700">
|
<p className="flex flex-1 items-center font-bold uppercase text-denim-700">
|
||||||
{props.icon ? (
|
{props.icon ? (
|
||||||
<span className="mr-2 text-xl">
|
<span className="mr-2 text-xl">
|
||||||
@@ -23,15 +20,8 @@ export function SectionHeading(props: SectionHeadingProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
{props.title}
|
{props.title}
|
||||||
</p>
|
</p>
|
||||||
{props.linkText ? (
|
|
||||||
<ArrowLink
|
|
||||||
linkText={props.linkText}
|
|
||||||
direction="left"
|
|
||||||
onClick={props.onClick}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
20
src/components/layout/Spinner.css
Normal file
20
src/components/layout/Spinner.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.spinner {
|
||||||
|
font-size: 48px;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
border: 0.12em solid var(--color,white);
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: spinner-rotation 800ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-rotation {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
9
src/components/layout/Spinner.tsx
Normal file
9
src/components/layout/Spinner.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import "./Spinner.css";
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner(props: SpinnerProps) {
|
||||||
|
return <div className={["spinner", props.className ?? ""].join(" ")} />;
|
||||||
|
}
|
@@ -8,7 +8,9 @@ interface ThinContainerProps {
|
|||||||
export function ThinContainer(props: ThinContainerProps) {
|
export function ThinContainer(props: ThinContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`max-w-[600px] mx-auto px-2 sm:px-0 ${props.classNames || ""}`}
|
className={`mx-auto w-[600px] max-w-full px-2 sm:px-0 ${
|
||||||
|
props.classNames || ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
18
src/components/layout/WideContainer.tsx
Normal file
18
src/components/layout/WideContainer.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface WideContainerProps {
|
||||||
|
classNames?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WideContainer(props: WideContainerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mx-auto w-[700px] max-w-full px-8 sm:px-4 ${
|
||||||
|
props.classNames || ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -9,12 +9,12 @@ export function Episode(props: EpisodeProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
className={`bg-denim-500 hover:bg-denim-400 transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded font-bold text-white active:scale-110 ${
|
className={`transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
|
||||||
props.active ? "shadow-bink-500 shadow-[inset_0_0_0_2px]" : ""
|
props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50"
|
className="absolute bottom-0 top-0 left-0 bg-bink-500 bg-opacity-50"
|
||||||
style={{
|
style={{
|
||||||
width: `${props.progress || 0}%`,
|
width: `${props.progress || 0}%`,
|
||||||
}}
|
}}
|
||||||
|
@@ -1,96 +1,145 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import { useTranslation } from "react-i18next";
|
||||||
convertMediaToPortable,
|
|
||||||
getProviderFromId,
|
|
||||||
MWMediaMeta,
|
|
||||||
MWMediaType,
|
|
||||||
} from "@/providers";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { serializePortableMedia } from "@/hooks/usePortableMedia";
|
|
||||||
import { DotList } from "@/components/text/DotList";
|
import { DotList } from "@/components/text/DotList";
|
||||||
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
|
import { JWMediaToId } from "@/backend/metadata/justwatch";
|
||||||
|
import { Icons } from "../Icon";
|
||||||
|
import { IconPatch } from "../buttons/IconPatch";
|
||||||
|
|
||||||
export interface MediaCardProps {
|
export interface MediaCardProps {
|
||||||
media: MWMediaMeta;
|
media: MWMediaMeta;
|
||||||
watchedPercentage: number;
|
|
||||||
linkable?: boolean;
|
linkable?: boolean;
|
||||||
series?: boolean;
|
series?: {
|
||||||
|
episode: number;
|
||||||
|
season: number;
|
||||||
|
episodeId: string;
|
||||||
|
seasonId: string;
|
||||||
|
};
|
||||||
|
percentage?: number;
|
||||||
|
closable?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaCardContent({
|
function MediaCardContent({
|
||||||
media,
|
media,
|
||||||
linkable,
|
linkable,
|
||||||
watchedPercentage,
|
|
||||||
series,
|
series,
|
||||||
|
percentage,
|
||||||
|
closable,
|
||||||
|
onClose,
|
||||||
}: MediaCardProps) {
|
}: MediaCardProps) {
|
||||||
const provider = getProviderFromId(media.providerId);
|
const { t } = useTranslation();
|
||||||
|
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||||
|
|
||||||
if (!provider) {
|
const canLink = linkable && !closable;
|
||||||
return null;
|
|
||||||
}
|
const dotListContent = [t(`media.${media.type}`)];
|
||||||
|
if (media.year) dotListContent.push(media.year);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<div
|
||||||
className={`group relative mb-4 flex overflow-hidden rounded bg-denim-300 py-4 px-5 ${
|
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||||
linkable ? "hover:bg-denim-400" : ""
|
canLink ? "hover:bg-opacity-100" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||||
|
canLink ? "group-hover:scale-95" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* progress background */}
|
|
||||||
{watchedPercentage > 0 ? (
|
|
||||||
<div className="absolute top-0 left-0 right-0 bottom-0">
|
|
||||||
<div
|
<div
|
||||||
className="relative h-full bg-bink-300 bg-opacity-30"
|
className={[
|
||||||
|
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
|
||||||
|
closable ? "" : "group-hover:rounded-lg",
|
||||||
|
].join(" ")}
|
||||||
style={{
|
style={{
|
||||||
width: `${watchedPercentage}%`,
|
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute right-0 top-0 bottom-0 ml-auto w-40 bg-gradient-to-l from-bink-400 to-transparent opacity-40" />
|
{series ? (
|
||||||
</div>
|
<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,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="relative flex flex-1">
|
{percentage !== undefined ? (
|
||||||
{/* card content */}
|
<>
|
||||||
<div className="flex-1">
|
<div
|
||||||
<h1 className="mb-1 font-bold text-white">
|
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||||
{media.title}
|
canLink ? "group-hover:from-denim-100" : ""
|
||||||
{series && media.seasonId && media.episodeId ? (
|
}`}
|
||||||
<span className="ml-2 text-xs text-denim-700">
|
/>
|
||||||
S{media.seasonId} E{media.episodeId}
|
<div
|
||||||
</span>
|
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||||
) : null}
|
canLink ? "group-hover:from-denim-100" : ""
|
||||||
</h1>
|
}`}
|
||||||
<DotList
|
/>
|
||||||
className="text-xs"
|
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||||
content={[provider.displayName, media.mediaType, media.year]}
|
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 rounded-full bg-bink-700"
|
||||||
|
style={{
|
||||||
|
width: percentageString,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* hoverable chevron */}
|
|
||||||
<div
|
<div
|
||||||
className={`flex translate-x-3 items-center justify-end text-xl text-white opacity-0 transition-[opacity,transform] ${
|
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
|
||||||
linkable ? "group-hover:translate-x-0 group-hover:opacity-100" : ""
|
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon icon={Icons.CHEVRON_RIGHT} />
|
<IconPatch
|
||||||
|
clickable
|
||||||
|
className="text-2xl text-slate-400"
|
||||||
|
onClick={() => closable && onClose?.()}
|
||||||
|
icon={Icons.X}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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={dotListContent} />
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaCard(props: MediaCardProps) {
|
export function MediaCard(props: MediaCardProps) {
|
||||||
let link = "movie";
|
|
||||||
if (props.media.mediaType === MWMediaType.SERIES) link = "series";
|
|
||||||
|
|
||||||
const content = <MediaCardContent {...props} />;
|
const content = <MediaCardContent {...props} />;
|
||||||
|
|
||||||
|
const canLink = props.linkable && !props.closable;
|
||||||
|
|
||||||
|
let link = canLink
|
||||||
|
? `/media/${encodeURIComponent(JWMediaToId(props.media))}`
|
||||||
|
: "#";
|
||||||
|
if (canLink && props.series)
|
||||||
|
link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent(
|
||||||
|
props.series.episodeId
|
||||||
|
)}`;
|
||||||
|
|
||||||
if (!props.linkable) return <span>{content}</span>;
|
if (!props.linkable) return <span>{content}</span>;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link to={link} className={props.closable ? "hover:cursor-default" : ""}>
|
||||||
to={`/media/${link}/${serializePortableMedia(
|
|
||||||
convertMediaToPortable(props.media)
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
15
src/components/media/MediaGrid.tsx
Normal file
15
src/components/media/MediaGrid.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
interface MediaGridProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
@@ -1,109 +0,0 @@
|
|||||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
|
||||||
import Hls from "hls.js";
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { Loading } from "@/components/layout/Loading";
|
|
||||||
import { MWMediaCaption, MWMediaStream } from "@/providers";
|
|
||||||
|
|
||||||
export interface VideoPlayerProps {
|
|
||||||
source: MWMediaStream;
|
|
||||||
captions: MWMediaCaption[];
|
|
||||||
startAt?: number;
|
|
||||||
onProgress?: (event: ProgressEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkeletonVideoPlayer(props: { error?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex aspect-video w-full items-center justify-center bg-denim-200 lg:rounded-xl">
|
|
||||||
{props.error ? (
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<IconPatch icon={Icons.WARNING} className="text-red-400" />
|
|
||||||
<p className="mt-5 text-white">Couldn't get your stream</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<Loading />
|
|
||||||
<p className="mt-3 text-white">Getting your stream...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoPlayer(props: VideoPlayerProps) {
|
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
||||||
const [hasErrored, setErrored] = useState(false);
|
|
||||||
const [isLoading, setLoading] = useState(true);
|
|
||||||
const showVideo = !isLoading && !hasErrored;
|
|
||||||
const mustUseHls = props.source.type === "m3u8";
|
|
||||||
|
|
||||||
// reset if stream url changes
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
setErrored(false);
|
|
||||||
|
|
||||||
// hls support
|
|
||||||
if (mustUseHls) {
|
|
||||||
if (!videoRef.current) return;
|
|
||||||
|
|
||||||
if (!Hls.isSupported()) {
|
|
||||||
setLoading(false);
|
|
||||||
setErrored(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hls = new Hls();
|
|
||||||
|
|
||||||
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
|
|
||||||
videoRef.current.src = props.source.url;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hls.attachMedia(videoRef.current);
|
|
||||||
hls.loadSource(props.source.url);
|
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
||||||
setErrored(true);
|
|
||||||
console.error(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [props.source.url, videoRef, mustUseHls]);
|
|
||||||
|
|
||||||
let skeletonUi: null | ReactElement = null;
|
|
||||||
if (hasErrored) {
|
|
||||||
skeletonUi = <SkeletonVideoPlayer error />;
|
|
||||||
} else if (isLoading) {
|
|
||||||
skeletonUi = <SkeletonVideoPlayer />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{skeletonUi}
|
|
||||||
<video
|
|
||||||
className={`w-full rounded-xl bg-black ${!showVideo ? "hidden" : ""}`}
|
|
||||||
ref={videoRef}
|
|
||||||
onProgress={(e) =>
|
|
||||||
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
|
|
||||||
}
|
|
||||||
onLoadedData={(e) => {
|
|
||||||
setLoading(false);
|
|
||||||
if (props.startAt)
|
|
||||||
(e.target as HTMLVideoElement).currentTime = props.startAt;
|
|
||||||
}}
|
|
||||||
onError={(e) => {
|
|
||||||
console.error("failed to playback stream", e);
|
|
||||||
setErrored(true);
|
|
||||||
}}
|
|
||||||
controls
|
|
||||||
autoPlay
|
|
||||||
>
|
|
||||||
{!mustUseHls ? (
|
|
||||||
<source src={props.source.url} type="video/mp4" />
|
|
||||||
) : null}
|
|
||||||
{props.captions.map((v) => (
|
|
||||||
<track key={v.id} kind="captions" label={v.label} src={v.url} />
|
|
||||||
))}
|
|
||||||
</video>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,25 +0,0 @@
|
|||||||
import { getEpisodeFromMedia, MWMedia } from "@/providers";
|
|
||||||
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched";
|
|
||||||
import { Episode } from "./EpisodeButton";
|
|
||||||
|
|
||||||
export interface WatchedEpisodeProps {
|
|
||||||
media: MWMedia;
|
|
||||||
onClick?: () => void;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WatchedEpisode(props: WatchedEpisodeProps) {
|
|
||||||
const { watched } = useWatchedContext();
|
|
||||||
const foundWatched = getWatchedFromPortable(watched.items, props.media);
|
|
||||||
const episode = getEpisodeFromMedia(props.media);
|
|
||||||
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Episode
|
|
||||||
progress={watchedPercentage}
|
|
||||||
episodeNumber={episode?.episode?.sort ?? 1}
|
|
||||||
active={props.active}
|
|
||||||
onClick={props.onClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,23 +1,44 @@
|
|||||||
import { MWMediaMeta } from "@/providers";
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched";
|
import { useWatchedContext } from "@/state/watched";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { MediaCard } from "./MediaCard";
|
import { MediaCard } from "./MediaCard";
|
||||||
|
|
||||||
export interface WatchedMediaCardProps {
|
export interface WatchedMediaCardProps {
|
||||||
media: MWMediaMeta;
|
media: MWMediaMeta;
|
||||||
series?: boolean;
|
closable?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeries(
|
||||||
|
obj:
|
||||||
|
| { episodeId: string; seasonId: string; episode: number; season: number }
|
||||||
|
| undefined
|
||||||
|
) {
|
||||||
|
if (!obj) return undefined;
|
||||||
|
return {
|
||||||
|
season: obj.season,
|
||||||
|
episode: obj.episode,
|
||||||
|
episodeId: obj.episodeId,
|
||||||
|
seasonId: obj.seasonId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||||
const { watched } = useWatchedContext();
|
const { watched } = useWatchedContext();
|
||||||
const foundWatched = getWatchedFromPortable(watched.items, props.media);
|
const watchedMedia = useMemo(() => {
|
||||||
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
|
return watched.items
|
||||||
|
.sort((a, b) => b.watchedAt - a.watchedAt)
|
||||||
|
.find((v) => v.item.meta.id === props.media.id);
|
||||||
|
}, [watched, props.media]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaCard
|
<MediaCard
|
||||||
watchedPercentage={watchedPercentage}
|
|
||||||
media={props.media}
|
media={props.media}
|
||||||
series={props.series && props.media.episodeId !== undefined}
|
series={formatSeries(watchedMedia?.item?.series)}
|
||||||
linkable
|
linkable
|
||||||
|
percentage={watchedMedia?.percentage}
|
||||||
|
onClose={props.onClose}
|
||||||
|
closable={props.closable}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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>;
|
||||||
|
}
|
189
src/components/popout/FloatingCard.tsx
Normal file
189
src/components/popout/FloatingCard.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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;
|
||||||
|
}) {
|
||||||
|
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 || "Go 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]" />;
|
||||||
|
}
|
39
src/components/popout/FloatingView.tsx
Normal file
39
src/components/popout/FloatingView.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,91 @@
|
|||||||
|
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] }) => {
|
||||||
|
if (closing.current) return;
|
||||||
|
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="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>
|
||||||
|
);
|
||||||
|
}
|
@@ -5,7 +5,7 @@ export interface DotListProps {
|
|||||||
|
|
||||||
export function DotList(props: DotListProps) {
|
export function DotList(props: DotListProps) {
|
||||||
return (
|
return (
|
||||||
<p className={`text-denim-700 font-semibold ${props.className || ""}`}>
|
<p className={`font-semibold text-denim-700 ${props.className || ""}`}>
|
||||||
{props.content.map((item, index) => (
|
{props.content.map((item, index) => (
|
||||||
<span key={item}>
|
<span key={item}>
|
||||||
{index !== 0 ? (
|
{index !== 0 ? (
|
||||||
|
@@ -16,22 +16,27 @@ interface ILinkPropsInternal extends ILinkPropsBase {
|
|||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LinkProps =
|
type LinkProps = ILinkPropsExternal | ILinkPropsInternal | ILinkPropsBase;
|
||||||
| ILinkPropsExternal
|
|
||||||
| ILinkPropsInternal
|
|
||||||
| ILinkPropsBase;
|
|
||||||
|
|
||||||
export function Link(props: LinkProps) {
|
export function Link(props: LinkProps) {
|
||||||
const isExternal = !!(props as ILinkPropsExternal).url;
|
const isExternal = !!(props as ILinkPropsExternal).url;
|
||||||
const isInternal = !!(props as ILinkPropsInternal).to;
|
const isInternal = !!(props as ILinkPropsInternal).to;
|
||||||
const content = (
|
const content = (
|
||||||
<span className="text-bink-600 hover:text-bink-700 cursor-pointer font-bold">
|
<span className="cursor-pointer font-bold text-bink-600 hover:text-bink-700">
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
return <a target={(props as ILinkPropsExternal).newTab ? "_blank" : undefined} rel="noreferrer" href={(props as ILinkPropsExternal).url}>{content}</a>;
|
return (
|
||||||
|
<a
|
||||||
|
target={(props as ILinkPropsExternal).newTab ? "_blank" : undefined}
|
||||||
|
rel="noreferrer"
|
||||||
|
href={(props as ILinkPropsExternal).url}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
if (isInternal)
|
if (isInternal)
|
||||||
return (
|
return (
|
||||||
<LinkRouter to={(props as ILinkPropsInternal).to}>{content}</LinkRouter>
|
<LinkRouter to={(props as ILinkPropsInternal).to}>{content}</LinkRouter>
|
||||||
|
@@ -1,7 +0,0 @@
|
|||||||
export interface TaglineProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tagline(props: TaglineProps) {
|
|
||||||
return <p className="font-bold text-bink-600">{props.children}</p>;
|
|
||||||
}
|
|
@@ -1,7 +1,16 @@
|
|||||||
export interface TitleProps {
|
export interface TitleProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Title(props: TitleProps) {
|
export function Title(props: TitleProps) {
|
||||||
return <h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white">{props.children}</h1>;
|
return (
|
||||||
|
<h1
|
||||||
|
className={`text-2xl font-bold text-white sm:text-3xl md:text-4xl ${
|
||||||
|
props.className ?? ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
|
}
|
110
src/hooks/useChromecastAvailable.ts
Normal file
110
src/hooks/useChromecastAvailable.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/// <reference types="chromecast-caf-sender"/>
|
||||||
|
|
||||||
|
import { isChromecastAvailable } from "@/setup/chromecast";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useChromecastAvailable() {
|
||||||
|
const [available, setAvailable] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isChromecastAvailable((bool) => setAvailable(bool));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChromecast() {
|
||||||
|
const available = useChromecastAvailable();
|
||||||
|
const instance = useRef<cast.framework.CastContext | null>(null);
|
||||||
|
const remotePlayerController =
|
||||||
|
useRef<cast.framework.RemotePlayerController | null>(null);
|
||||||
|
|
||||||
|
function startCast() {
|
||||||
|
const movieMeta = new chrome.cast.media.MovieMediaMetadata();
|
||||||
|
movieMeta.title = "Big Buck Bunny";
|
||||||
|
|
||||||
|
const mediaInfo = new chrome.cast.media.MediaInfo("hello", "video/mp4");
|
||||||
|
(mediaInfo as any).contentUrl =
|
||||||
|
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||||
|
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
|
||||||
|
mediaInfo.metadata = movieMeta;
|
||||||
|
|
||||||
|
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||||
|
request.autoplay = true;
|
||||||
|
|
||||||
|
const session = instance.current?.getCurrentSession();
|
||||||
|
console.log("testing", session);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
session
|
||||||
|
.loadMedia(request)
|
||||||
|
.then(() => {
|
||||||
|
console.log("Media is loaded");
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCast() {
|
||||||
|
const session = instance.current?.getCurrentSession();
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const controller = remotePlayerController.current;
|
||||||
|
if (!controller) return;
|
||||||
|
controller.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!available) return;
|
||||||
|
|
||||||
|
// setup instance if not already
|
||||||
|
if (!instance.current) {
|
||||||
|
const ins = cast.framework.CastContext.getInstance();
|
||||||
|
ins.setOptions({
|
||||||
|
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||||
|
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||||
|
});
|
||||||
|
instance.current = ins;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup player if not already
|
||||||
|
if (!remotePlayerController.current) {
|
||||||
|
const player = new cast.framework.RemotePlayer();
|
||||||
|
const controller = new cast.framework.RemotePlayerController(player);
|
||||||
|
remotePlayerController.current = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup event listener
|
||||||
|
function listenToEvents(e: cast.framework.RemotePlayerChangedEvent) {
|
||||||
|
console.log("chromecast event", e);
|
||||||
|
}
|
||||||
|
function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) {
|
||||||
|
console.log("chromecast event connection changed", e);
|
||||||
|
}
|
||||||
|
remotePlayerController.current.addEventListener(
|
||||||
|
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
|
||||||
|
listenToEvents
|
||||||
|
);
|
||||||
|
remotePlayerController.current.addEventListener(
|
||||||
|
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
|
||||||
|
connectionChanged
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
remotePlayerController.current?.removeEventListener(
|
||||||
|
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
|
||||||
|
listenToEvents
|
||||||
|
);
|
||||||
|
remotePlayerController.current?.removeEventListener(
|
||||||
|
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
|
||||||
|
connectionChanged
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [available]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startCast,
|
||||||
|
stopCast,
|
||||||
|
};
|
||||||
|
}
|
@@ -4,17 +4,14 @@ export function useDebounce<T>(value: T, delay: number): T {
|
|||||||
// State and setters for debounced value
|
// State and setters for debounced value
|
||||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => {
|
||||||
() => {
|
|
||||||
const handler = setTimeout(() => {
|
const handler = setTimeout(() => {
|
||||||
setDebouncedValue(value);
|
setDebouncedValue(value);
|
||||||
}, delay);
|
}, delay);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(handler);
|
clearTimeout(handler);
|
||||||
};
|
};
|
||||||
},
|
}, [value, delay]);
|
||||||
[value, delay]
|
|
||||||
);
|
|
||||||
|
|
||||||
return debouncedValue;
|
return debouncedValue;
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import './useFade.css'
|
import "./useFade.css";
|
||||||
|
|
||||||
export const useFade = (initial = false): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => {
|
export const useFade = (
|
||||||
|
initial = false
|
||||||
|
): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => {
|
||||||
const [show, setShow] = useState<boolean>(initial);
|
const [show, setShow] = useState<boolean>(initial);
|
||||||
const [isVisible, setVisible] = useState<boolean>(show);
|
const [isVisible, setVisible] = useState<boolean>(show);
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ export const useFade = (initial = false): [boolean, React.Dispatch<React.SetStat
|
|||||||
// These props go on the fading DOM element
|
// These props go on the fading DOM element
|
||||||
const fadeProps = {
|
const fadeProps = {
|
||||||
style,
|
style,
|
||||||
onAnimationEnd
|
onAnimationEnd,
|
||||||
};
|
};
|
||||||
|
|
||||||
return [isVisible, setShow, fadeProps];
|
return [isVisible, setShow, fadeProps];
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
12
src/hooks/useGoBack.ts
Normal file
12
src/hooks/useGoBack.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useHistory } from "react-router-dom";
|
||||||
|
|
||||||
|
export function useGoBack() {
|
||||||
|
const reactHistory = useHistory();
|
||||||
|
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
if (reactHistory.action !== "POP") reactHistory.goBack();
|
||||||
|
else reactHistory.push("/");
|
||||||
|
}, [reactHistory]);
|
||||||
|
return goBack;
|
||||||
|
}
|
30
src/hooks/useIsMobile.ts
Normal file
30
src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useIsMobile(horizontal?: boolean) {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const isMobileCurrent = useRef<boolean | null>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onResize() {
|
||||||
|
const value = horizontal
|
||||||
|
? window.innerHeight < 600
|
||||||
|
: window.innerWidth < 1024;
|
||||||
|
const isChanged = isMobileCurrent.current !== value;
|
||||||
|
if (!isChanged) return;
|
||||||
|
|
||||||
|
isMobileCurrent.current = value;
|
||||||
|
setIsMobile(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onResize();
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, [horizontal]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMobile,
|
||||||
|
};
|
||||||
|
}
|
@@ -2,7 +2,12 @@ import React, { useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
export function useLoading<T extends (...args: any) => Promise<any>>(
|
export function useLoading<T extends (...args: any) => Promise<any>>(
|
||||||
action: T
|
action: T
|
||||||
) {
|
): [
|
||||||
|
(...args: Parameters<T>) => ReturnType<T> | Promise<undefined>,
|
||||||
|
boolean,
|
||||||
|
Error | undefined,
|
||||||
|
boolean
|
||||||
|
] {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [error, setError] = useState<any | undefined>(undefined);
|
const [error, setError] = useState<any | undefined>(undefined);
|
||||||
@@ -20,11 +25,11 @@ export function useLoading<T extends (...args: any) => Promise<any>>(
|
|||||||
|
|
||||||
const doAction = useMemo(
|
const doAction = useMemo(
|
||||||
() =>
|
() =>
|
||||||
async (...args: Parameters<T>) => {
|
async (...args: any) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
return new Promise((resolve) => {
|
return new Promise<any>((resolve) => {
|
||||||
actionMemo(...args)
|
actionMemo(...args)
|
||||||
.then((v) => {
|
.then((v) => {
|
||||||
if (!isMounted.current) return resolve(undefined);
|
if (!isMounted.current) return resolve(undefined);
|
||||||
@@ -35,6 +40,7 @@ export function useLoading<T extends (...args: any) => Promise<any>>(
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setError(err);
|
setError(err);
|
||||||
|
console.error("USELOADING ERROR", err);
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
}
|
}
|
||||||
resolve(undefined);
|
resolve(undefined);
|
||||||
|
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;
|
||||||
|
}
|
@@ -1,30 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { MWPortableMedia } from "@/providers";
|
|
||||||
|
|
||||||
export function deserializePortableMedia(media: string): MWPortableMedia {
|
|
||||||
return JSON.parse(atob(decodeURIComponent(media)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializePortableMedia(media: MWPortableMedia): string {
|
|
||||||
const data = encodeURIComponent(btoa(JSON.stringify(media)));
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePortableMedia(): MWPortableMedia | undefined {
|
|
||||||
const { media } = useParams<{ media: string }>();
|
|
||||||
const [mediaObject, setMediaObject] = useState<MWPortableMedia | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
setMediaObject(deserializePortableMedia(media));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to deserialize portable media", err);
|
|
||||||
setMediaObject(undefined);
|
|
||||||
}
|
|
||||||
}, [media, setMediaObject]);
|
|
||||||
|
|
||||||
return mediaObject;
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user