Compare commits

...

108 Commits
3.0.0 ... 3.0.5

Author SHA1 Message Date
mrjvs
1c77807987 Merge pull request #196 from movie-web/dev
V3.0.5
2023-03-10 21:21:59 +01:00
mrjvs
9bba47575a Merge branch 'master' into dev 2023-03-10 21:09:18 +01:00
mrjvs
dace2338be bump version 2023-03-10 21:06:03 +01:00
mrjvs
30d8e11992 Merge pull request #189 from lem6ns/external_ids
fix(meta): fallback to no "_latest"
2023-03-10 21:04:27 +01:00
mrjvs
9c9ce92681 Merge branch 'dev' into external_ids 2023-03-10 21:00:56 +01:00
mrjvs
30cc5aa78b fix more linting 2023-03-10 20:59:10 +01:00
mrjvs
ac28f32ef4 fix linting and make code nicer 2023-03-10 20:54:56 +01:00
mrjvs
fca9fea265 Merge pull request #194 from movie-web/feature-frame-protection
Add security headers
2023-03-10 20:45:43 +01:00
James Hawkins
c2bd7714ed Merge branch 'dev' into feature-frame-protection 2023-03-10 19:40:37 +00:00
mrjvs
48214af202 Merge pull request #175 from zisra/dev
Add Picture-in-picture
2023-03-10 20:29:55 +01:00
mrjvs
72ad53ee56 add security headers 2023-03-10 20:23:14 +01:00
mrjvs
02d94ba411 Merge branch 'dev' into dev 2023-03-10 19:49:51 +01:00
mrjvs
84913aa63d Merge branch 'dev' into external_ids 2023-03-10 19:48:58 +01:00
mrjvs
9d7ddc03a5 name annotation jobs 2023-03-10 19:41:32 +01:00
mrjvs
5327cbffaa update annotate download script to use v6 2023-03-10 19:38:59 +01:00
mrjvs
695ccef2b5 added yarn cache to deployment script 2023-03-10 19:35:51 +01:00
mrjvs
addd8ca031 fix wrong version 2023-03-10 19:34:25 +01:00
mrjvs
dd662efd72 Merge pull request #192 from movie-web/fix-ci-lineendings
update linting ci
2023-03-10 19:28:26 +01:00
mrjvs
900c70e36a update ci 2023-03-10 19:25:14 +01:00
mrjvs
68a1470447 seperate building and linting 2023-03-10 19:17:11 +01:00
mrjvs
b42d36c5ac fix lint errors 2023-03-10 19:12:22 +01:00
mrjvs
6b9774a210 update linting ci 2023-03-10 19:10:08 +01:00
James Hawkins
a5cd05b144 Merge branch 'dev' into external_ids 2023-03-10 07:09:31 +00:00
James Hawkins
bdb4b3507a Merge pull request #187 from lem6ns/dev
fix(netfilm): use different cdn
2023-03-10 07:08:23 +00:00
cloud
ca6383900a fix(meta): fallback to no "_latest" 2023-03-09 19:22:41 -07:00
cloud
5e97a195d9 fix: vscode settings file 2023-03-09 15:37:06 -07:00
cloud
25e32a14b7 feat(netfilm): add captions 2023-03-09 15:35:39 -07:00
cloud
139a760be0 fix(netfilm): use different cdn 2023-03-09 15:34:54 -07:00
zisra
a3e244285c mrvjs suggested changes 2023-03-04 10:24:56 -06:00
mrjvs
935cb2427b Merge pull request #178 from frost768/dev
feature: subtitle uploading
2023-03-04 15:41:43 +01:00
frost768
404cd897f3 feature: subtitle uploading 2023-03-03 19:33:30 +03:00
zisra
fac0a878f3 More fixes 2023-02-28 13:04:01 -06:00
zisra
596e680a18 TypeScript fix 2023-02-28 13:03:06 -06:00
zisra
c6bf568514 Attempt to fix types 2023-02-28 11:26:30 -06:00
zisra
4a38c77e2d Fix feature detection 2023-02-27 17:44:50 -06:00
zisra
163ca0df29 Fix isPictureInPicture 2023-02-27 17:35:56 -06:00
zisra
3fad6edaad Webkit support 2023-02-27 03:43:14 -06:00
zisra
f2f7925cbb CSS changes 2023-02-27 01:19:38 -06:00
zisra
b9026c50f5 Picture in picture 2023-02-27 00:58:47 -06:00
zisra
a1f3986e64 Picture in picture 2023-02-27 00:58:36 -06:00
mrjvs
224cdb6710 Merge pull request #172 from movie-web/dev
version 3.0.4
2023-02-24 23:22:48 +01:00
mrjvs
f76db3e4b7 Merge branch 'master' into dev 2023-02-24 23:18:37 +01:00
mrjvs
9abb009725 bump version 2023-02-24 23:14:27 +01:00
mrjvs
0ca4b3cf49 Merge pull request #171 from movie-web/feature-pwa
PWA
2023-02-24 23:11:01 +01:00
mrjvs
9418a7c45d Merge branch 'dev' into feature-pwa 2023-02-24 23:10:45 +01:00
mrjvs
d34d2c8ce0 review changes 2023-02-24 23:09:27 +01:00
mrjvs
281785a0ef Merge pull request #157 from zisra/dev
Download button
2023-02-24 22:35:46 +01:00
mrjvs
28c008a77f add any purpose 2023-02-24 22:20:35 +01:00
mrjvs
717ebbaeae maskable icon 2023-02-24 22:16:51 +01:00
mrjvs
f715f70f9e fix layout sizings
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-24 22:12:31 +01:00
mrjvs
24aeb68f55 error boundary
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-24 21:45:14 +01:00
zisra
8ed0d3740f Merge branch 'movie-web:dev' into dev 2023-02-24 13:32:47 -06:00
mrjvs
444c751b78 cache busting pwa 2023-02-24 20:12:20 +01:00
mrjvs
63b9adf7d8 disable gdriveplayer 2023-02-24 19:23:26 +01:00
mrjvs
3a1c3ad260 add PWA support 2023-02-24 19:23:00 +01:00
James Hawkins
e68fe0e115 Update netfilm.ts 2023-02-24 14:22:06 +00:00
James Hawkins
d51246120d Update flixhq.ts 2023-02-24 13:24:45 +00:00
James Hawkins
23b439ff79 Temporarily fix flixhq provider
This fix can be used whilst we wait for api.consumet.org to resolve their issues. See https://github.com/consumet/api.consumet.org/issues/326 for more information.
2023-02-24 13:06:05 +00:00
zisra
ac350f276c Merge branch 'movie-web:dev' into dev 2023-02-22 19:27:13 -06:00
mrjvs
854e6bede4 Merge pull request #169 from movie-web/feature-developer-tooling
Development tooling, round robin and better settings
2023-02-22 22:13:16 +01:00
mrjvs
25670814e4 fix tsconfig types 2023-02-22 22:08:11 +01:00
mrjvs
7c2ad68c2a add default for NORMAL_ROUTER setting 2023-02-22 21:54:02 +01:00
mrjvs
e82173efbe update script 2023-02-22 21:49:58 +01:00
mrjvs
485698a43c support for round robin proxies 2023-02-22 21:41:13 +01:00
mrjvs
444156236c add unit tests for providers 2023-02-22 21:15:37 +01:00
mrjvs
4f9ef382dc provider and embed scraper tools 2023-02-22 20:26:19 +01:00
mrjvs
cedc987509 Add developer video testing page 2023-02-22 19:02:23 +01:00
zisra
a99437b4cc Fix title 2023-02-21 15:07:40 -06:00
James Hawkins
7f28e7be3d Merge pull request #167 from movie-web/dev
version 3.0.3
2023-02-21 20:55:50 +00:00
James Hawkins
efc2c8a67d Merge branch 'master' into dev 2023-02-21 20:53:42 +00:00
mrjvs
02cd565f84 version bump 2023-02-21 21:51:57 +01:00
James Hawkins
0625719a4d Merge pull request #166 from movie-web/feature-react-ga
setup GA properly
2023-02-21 20:49:23 +00:00
mrjvs
16298431f4 Merge branch 'dev' into feature-react-ga 2023-02-21 21:49:10 +01:00
James Hawkins
7d6656aef2 Merge pull request #151 from maxwellward/QOL-fixes
Quality of life fixes
2023-02-21 20:48:56 +00:00
James Hawkins
564bcccff8 Merge branch 'dev' into feature-react-ga 2023-02-21 20:48:17 +00:00
James Hawkins
177df9a6f2 Merge branch 'dev' into QOL-fixes 2023-02-21 20:46:31 +00:00
mrjvs
e44b36c83e update tracking 2023-02-21 21:45:14 +01:00
zisra
3696a05e1e Fix suggested changes 2023-02-21 14:17:36 -06:00
mrjvs
abeb68d4a3 Merge pull request #165 from movie-web/dark-reader-fix
Fix darkreader
2023-02-21 19:52:15 +01:00
mrjvs
d10d4faf56 darkreader lock 2023-02-21 19:47:14 +01:00
zisra
f5e5b48616 Update VideoPlayer.tsx 2023-02-20 20:28:09 -06:00
zisra
9ff49e42a3 Update Icon.tsx 2023-02-20 20:26:51 -06:00
zisra
d6a46e1cdc Update Icon.tsx 2023-02-20 20:23:06 -06:00
zisra
d10cbd5e9b Update VideoPlayer.tsx 2023-02-20 20:20:19 -06:00
zisra
1853c8eac7 Create DownloadAction.tsx 2023-02-20 20:18:38 -06:00
Max Ward
6908588c00 Merge branch 'dev' into QOL-fixes 2023-02-20 18:11:38 -08:00
Max Ward
48ab781bb9 Merge branch 'QOL-fixes' of https://github.com/maxwellward/movie-web into QOL-fixes 2023-02-20 18:10:34 -08:00
Max Ward
fbd683e0b5 implement comment fixes 2023-02-20 18:10:22 -08:00
mrjvs
3b3457532a Merge pull request #156 from movie-web/dev
new version
2023-02-20 18:23:08 +01:00
mrjvs
ef7b9ff475 Merge pull request #155 from JipFr/v3-iframe-migration
Bump versions
2023-02-20 18:20:43 +01:00
Jip Fr
c5aacd72ce Bump versions 2023-02-20 18:19:37 +01:00
mrjvs
620e63f17c Merge pull request #154 from JipFr/v3-iframe-migration
V3 iframe migration
2023-02-20 18:18:37 +01:00
Jip Fr
4d8257a05f Remove unused imports 2023-02-20 18:16:48 +01:00
James Hawkins
0f9d7faaf2 Update README.md 2023-02-20 16:52:17 +00:00
Jip Fr
afa89c02a0 Add iframe logic 2023-02-20 17:35:09 +01:00
Max Ward
2bef75dd4a update readme to reflect proper local run command 2023-02-19 22:35:40 -08:00
Max Ward
35adaf3872 add horizontal check to isMobile helper 2023-02-19 22:25:49 -08:00
Max Ward
a2e5e08b20 shrink popouts when on horizontal mobile devices 2023-02-19 21:49:52 -08:00
Max Ward
39ede1b042 improve mobile video player 2023-02-19 21:20:42 -08:00
Max Ward
32288357c4 fix too much darkness fade under search 2023-02-19 18:44:27 -08:00
Max Ward
35ecaece5b make title text fade behind header 2023-02-19 18:42:52 -08:00
Max Ward
25ccd941ca fix some hover states and rounding in edit mode 2023-02-19 18:18:34 -08:00
Max Ward
bfbb4c6b11 reduce space below search on mobile 2023-02-19 17:59:22 -08:00
mrjvs
f13ed7cae1 Merge pull request #148 from movie-web/dev
reorder providers
2023-02-20 00:51:22 +01:00
mrjvs
44f59e9708 Merge branch 'master' into dev 2023-02-20 00:51:07 +01:00
mrjvs
92fa9716e5 reorder providers 2023-02-20 00:50:30 +01:00
mrjvs
e289f9a228 Merge pull request #146 from movie-web/dev
update version number
2023-02-19 23:59:30 +01:00
mrjvs
68868b37a8 update version 2023-02-19 23:58:52 +01:00
70 changed files with 4030 additions and 307 deletions

View File

@@ -43,6 +43,7 @@ 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",

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

@@ -18,12 +18,13 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18 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@v3 uses: actions/upload-artifact@v3

48
.github/workflows/linting_annotate.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Annotate linting
permissions:
actions: read # download artifact
checks: write # annotate
# this is done as a seperate workflow so
# the annotater has access to write to checks (to annotate)
on:
workflow_run:
workflows: ["Linting and Testing"]
types:
- completed
jobs:
annotate:
name: Annotate linting
runs-on: ubuntu-latest
steps:
- name: Download linting report
uses: actions/github-script@v6
with:
script: |
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{github.event.workflow_run.id }},
});
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "eslint_report.json"
})[0];
const download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('${{github.workspace}}/eslint_report.zip', Buffer.from(download.data));
- run: unzip eslint_report.zip
- name: Annotate linting
uses: ataylorme/eslint-annotate-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
report-json: "eslint_report.json"

View File

@@ -5,8 +5,7 @@ on:
branches: branches:
- master - master
- dev - dev
pull_request_target: pull_request:
types: [opened, reopened, synchronize]
jobs: jobs:
linting: linting:
@@ -21,6 +20,7 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 18
cache: 'yarn'
- name: Install Yarn packages - name: Install Yarn packages
run: yarn install run: yarn install
@@ -30,11 +30,27 @@ jobs:
# continue on error, so it still reports it in the next step # continue on error, so it still reports it in the next step
continue-on-error: true continue-on-error: true
- name: Annotate Code Linting Results - uses: actions/upload-artifact@v3
uses: ataylorme/eslint-annotate-action@v2
with: with:
repo-token: "${{ secrets.GITHUB_TOKEN }}" name: eslint_report.json
report-json: "eslint_report.json" path: eslint_report.json
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 - name: Build Project
run: npm run build run: yarn build

1
.gitignore vendored
View File

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

View File

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

View File

@@ -40,7 +40,7 @@ To run this project locally for contributing or testing, run the following comma
git clone https://github.com/movie-web/movie-web 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`.

View File

@@ -1,36 +1,20 @@
<!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, viewport-fit=cover" /> <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 />
@@ -40,7 +24,10 @@
/> />
<script src="config.js"></script> <script src="config.js"></script>
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@3744edbc5f64a77985b6421ea5040e688663634b/out.js"></script> <script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@8b821f445b83d51ef1b8f42c99b7346f6b47dce5/out.js"></script>
<!-- prevent darkreader extension from messing with our already dark site -->
<meta name="darkreader-lock" />
<title>movie-web</title> <title>movie-web</title>
</head> </head>

View File

@@ -1,6 +1,6 @@
{ {
"name": "movie-web", "name": "movie-web",
"version": "3.0.0", "version": "3.0.5",
"private": true, "private": true,
"homepage": "https://movie.squeezebox.dev", "homepage": "https://movie.squeezebox.dev",
"dependencies": { "dependencies": {
@@ -20,17 +20,20 @@
"pako": "^2.1.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-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-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5", "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:fix": "eslint --fix --ext .tsx,.ts src", "lint:fix": "eslint --fix --ext .tsx,.ts src",
@@ -75,6 +78,7 @@
"eslint-plugin-prettier": "^4.2.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",
@@ -83,6 +87,9 @@
"typescript": "^4.6.4", "typescript": "^4.6.4",
"vite": "^4.0.1", "vite": "^4.0.1",
"vite-plugin-checker": "^0.5.6", "vite-plugin-checker": "^0.5.6",
"vite-plugin-package-version": "^1.0.2" "vite-plugin-package-version": "^1.0.2",
"vite-plugin-pwa": "^0.14.4",
"vitest": "^0.28.5",
"workbox-window": "^6.5.4"
} }
} }

5
public/_headers Normal file
View 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

View File

@@ -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>

View File

@@ -1,7 +1,6 @@
window.__CONFIG__ = { window.__CONFIG__ = {
// url must NOT end with a slash // url must NOT end with a slash
VITE_CORS_PROXY_URL: "", VITE_CORS_PROXY_URL: "",
VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3", VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3",
VITE_OMDB_API_KEY: "aa0937c0" VITE_OMDB_API_KEY: "aa0937c0",
}; };

1
public/ping.txt Normal file
View File

@@ -0,0 +1 @@
pong

View File

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

View File

@@ -0,0 +1,51 @@
import { describe, it } from "vitest";
import "@/backend";
import { getProviders } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types";
import { runProvider } from "@/backend/helpers/run";
import { testData } from "@/__tests__/providers/testdata";
describe("providers", () => {
const providers = getProviders();
it("have at least one provider", ({ expect }) => {
expect(providers.length).toBeGreaterThan(0);
});
for (const provider of providers) {
describe(provider.displayName, () => {
it("must have at least one type", async ({ expect }) => {
expect(provider.type.length).toBeGreaterThan(0);
});
if (provider.type.includes(MWMediaType.MOVIE)) {
it("must work with movies", async ({ expect }) => {
const movie = testData.find((v) => v.meta.type === MWMediaType.MOVIE);
if (!movie) throw new Error("no movie to test with");
const results = await runProvider(provider, {
media: movie,
progress() {},
type: movie.meta.type as any,
});
expect(results).toBeTruthy();
});
}
if (provider.type.includes(MWMediaType.SERIES)) {
it("must work with series", async ({ expect }) => {
const show = testData.find((v) => v.meta.type === MWMediaType.SERIES);
if (show?.meta.type !== MWMediaType.SERIES)
throw new Error("no show to test with");
const results = await runProvider(provider, {
media: show,
progress() {},
type: show.meta.type as MWMediaType.SERIES,
episode: show.meta.seasonData.episodes[0].id,
season: show.meta.seasons[0].id,
});
expect(results).toBeTruthy();
});
}
});
}
});

View File

@@ -0,0 +1,45 @@
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types";
export const testData: DetailedMeta[] = [
{
imdbId: "tt10954562",
tmdbId: "572716",
meta: {
id: "439596",
title: "Hamilton",
type: MWMediaType.MOVIE,
year: "2020",
seasons: undefined,
},
},
{
imdbId: "tt11126994",
tmdbId: "94605",
meta: {
id: "222333",
title: "Arcane",
type: MWMediaType.SERIES,
year: "2021",
seasons: [
{
id: "230301",
number: 1,
title: "Season 1",
},
],
seasonData: {
id: "230301",
number: 1,
title: "Season 1",
episodes: [
{
id: "4243445",
number: 1,
title: "Welcome to the Playground",
},
],
},
},
},
];

View File

@@ -2,6 +2,7 @@ import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import toWebVTT from "srt-webvtt"; import toWebVTT from "srt-webvtt";
export const CUSTOM_CAPTION_ID = "customCaption";
export async function getCaptionUrl(caption: MWCaption): Promise<string> { export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.type === MWCaptionType.SRT) { if (caption.type === MWCaptionType.SRT) {
let captionBlob: Blob; let captionBlob: Blob;
@@ -32,3 +33,18 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
throw new Error("invalid type"); 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);
}
}

View File

@@ -1,6 +1,15 @@
import { conf } from "@/setup/config"; import { conf } from "@/setup/config";
import { ofetch } from "ofetch"; 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 P<T> = Parameters<typeof ofetch<T>>;
type R<T> = ReturnType<typeof ofetch<T>>; type R<T> = ReturnType<typeof ofetch<T>>;
@@ -41,7 +50,7 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
parsedUrl.searchParams.set(k, v); parsedUrl.searchParams.set(k, v);
}); });
return baseFetch<T>(conf().BASE_PROXY_URL, { return baseFetch<T>(getProxyUrl(), {
...ops, ...ops,
baseURL: undefined, baseURL: undefined,
params: { params: {

View File

@@ -54,12 +54,17 @@ export async function getMetaFromId(
throw err; throw err;
} }
const imdbId = data.external_ids.find( let imdbId = data.external_ids.find(
(v) => v.provider === "imdb_latest" (v) => v.provider === "imdb_latest"
)?.external_id; )?.external_id;
const tmdbId = data.external_ids.find( if (!imdbId)
imdbId = data.external_ids.find((v) => v.provider === "imdb")?.external_id;
let tmdbId = data.external_ids.find(
(v) => v.provider === "tmdb_latest" (v) => v.provider === "tmdb_latest"
)?.external_id; )?.external_id;
if (!tmdbId)
tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id;
if (!imdbId || !tmdbId) throw new Error("not enough info"); if (!imdbId || !tmdbId) throw new Error("not enough info");

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
import { proxiedFetch } from "../helpers/fetch"; import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register"; import { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams"; import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types"; import { MWMediaType } from "../metadata/types";
const netfilmBase = "https://net-film.vercel.app"; const netfilmBase = "https://net-film.vercel.app";
@@ -16,7 +20,7 @@ type QualityInMap = keyof typeof qualityMap;
registerProvider({ registerProvider({
id: "netfilm", id: "netfilm",
displayName: "NetFilm", displayName: "NetFilm",
rank: 150, rank: 15,
type: [MWMediaType.MOVIE, MWMediaType.SERIES], type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) { async scrape({ media, episode, progress }) {
@@ -47,20 +51,29 @@ registerProvider({
} }
); );
const { qualities } = watchInfo.data; const data = watchInfo.data;
// get best quality source // get best quality source
const source = qualities.reduce((p: any, c: any) => const source = data.qualities.reduce((p: any, c: any) =>
c.quality > p.quality ? c : p 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 { return {
embeds: [], embeds: [],
stream: { stream: {
streamUrl: source.url, streamUrl: source.url
.replace("akm-cdn", "aws-cdn")
.replace("gg-cdn", "aws-cdn"),
quality: qualityMap[source.quality as QualityInMap], quality: qualityMap[source.quality as QualityInMap],
type: MWStreamType.HLS, type: MWStreamType.HLS,
captions: [], captions: mappedCaptions,
}, },
}; };
} }
@@ -108,20 +121,29 @@ registerProvider({
} }
); );
const { qualities } = episodeStream.data; const data = episodeStream.data;
// get best quality source // get best quality source
const source = qualities.reduce((p: any, c: any) => const source = data.qualities.reduce((p: any, c: any) =>
c.quality > p.quality ? c : p 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 { return {
embeds: [], embeds: [],
stream: { stream: {
streamUrl: source.url, streamUrl: source.url
.replace("akm-cdn", "aws-cdn")
.replace("gg-cdn", "aws-cdn"),
quality: qualityMap[source.quality as QualityInMap], quality: qualityMap[source.quality as QualityInMap],
type: MWStreamType.HLS, type: MWStreamType.HLS,
captions: [], captions: mappedCaptions,
}, },
}; };
}, },

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

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

View File

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

View File

@@ -1,7 +1,10 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon"; 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 (
@@ -13,7 +16,14 @@ export function BrandPill(props: { clickable?: boolean }) {
}`} }`}
> >
<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>
); );
} }

View File

@@ -3,6 +3,7 @@ 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 { conf } from "@/setup/config"; import { conf } from "@/setup/config";
import { useBannerSize } from "@/hooks/useBanner";
import { BrandPill } from "./BrandPill"; import { BrandPill } from "./BrandPill";
export interface NavigationProps { export interface NavigationProps {
@@ -11,44 +12,53 @@ export interface NavigationProps {
} }
export function Navigation(props: NavigationProps) { export function Navigation(props: NavigationProps) {
const bannerHeight = useBannerSize();
return ( return (
<div className="fixed left-0 right-0 top-0 z-10 flex min-h-[88px] items-center justify-between py-5 px-7"> <div
<div className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
className={`${ style={{
props.bg ? "opacity-100" : "opacity-0" top: `${bannerHeight}px`,
} 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 className="fixed left-0 right-0 flex items-center justify-between py-5 px-7">
</div> <div
<div className="relative flex w-full items-center justify-center sm:w-fit"> className={`${
<div className="mr-auto sm:mr-6"> props.bg ? "opacity-100" : "opacity-0"
<Link to="/"> } absolute inset-0 block bg-denim-100 transition-opacity duration-300`}
<BrandPill clickable /> >
</Link> <div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
</div> </div>
{props.children} <div className="relative flex w-full items-center justify-center sm:w-fit">
</div> <div className="mr-auto sm:mr-6">
<div <Link to="/">
className={`${ <BrandPill clickable />
props.children ? "hidden sm:flex" : "flex" </Link>
} relative flex-row gap-4`} </div>
> {props.children}
<a </div>
href={conf().DISCORD_LINK} <div
target="_blank" className={`${
rel="noreferrer" props.children ? "hidden sm:flex" : "flex"
className="text-2xl text-white" } relative flex-row gap-4`}
> >
<IconPatch icon={Icons.DISCORD} clickable /> <a
</a> href={conf().DISCORD_LINK}
<a target="_blank"
href={conf().GITHUB_LINK} rel="noreferrer"
target="_blank" className="text-2xl text-white"
rel="noreferrer" >
className="text-2xl text-white" <IconPatch icon={Icons.DISCORD} clickable />
> </a>
<IconPatch icon={Icons.GITHUB} clickable /> <a
</a> href={conf().GITHUB_LINK}
target="_blank"
rel="noreferrer"
className="text-2xl text-white"
>
<IconPatch icon={Icons.GITHUB} clickable />
</a>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -10,8 +10,8 @@ interface SectionHeadingProps {
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">

View File

@@ -45,14 +45,27 @@ function MediaCardContent({
}`} }`}
> >
<div <div
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100 group-hover:rounded-lg" className={[
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
closable ? "" : "group-hover:rounded-lg",
].join(" ")}
style={{ style={{
backgroundImage: media.poster ? `url(${media.poster})` : undefined, backgroundImage: media.poster ? `url(${media.poster})` : undefined,
}} }}
> >
{series ? ( {series ? (
<div className="absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors group-hover:bg-denim-500"> <div
<p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white"> 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", { {t("seasons.seasonAndEpisode", {
season: series.season, season: series.season,
episode: series.episode, episode: series.episode,
@@ -125,5 +138,9 @@ export function MediaCard(props: MediaCardProps) {
)}`; )}`;
if (!props.linkable) return <span>{content}</span>; if (!props.linkable) return <span>{content}</span>;
return <Link to={link}>{content}</Link>; return (
<Link to={link} className={props.closable ? "hover:cursor-default" : ""}>
{content}
</Link>
);
} }

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

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

View File

@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
export function useIsMobile() { export function useIsMobile(horizontal?: boolean) {
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const isMobileCurrent = useRef<boolean | null>(false); const isMobileCurrent = useRef<boolean | null>(false);
useEffect(() => { useEffect(() => {
function onResize() { function onResize() {
const value = window.innerWidth < 1024; const value = horizontal
? window.innerHeight < 600
: window.innerWidth < 1024;
const isChanged = isMobileCurrent.current !== value; const isChanged = isMobileCurrent.current !== value;
if (!isChanged) return; if (!isChanged) return;
@@ -20,7 +22,7 @@ export function useIsMobile() {
return () => { return () => {
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
}; };
}, []); }, [horizontal]);
return { return {
isMobile, isMobile,

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

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

View File

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

View File

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

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

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

View File

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

View File

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

8
src/setup/ga.ts Normal file
View File

@@ -0,0 +1,8 @@
import ReactGA from "react-ga4";
import { GA_ID } from "@/setup/constants";
ReactGA.initialize([
{
trackingId: GA_ID,
},
]);

View File

@@ -4,12 +4,13 @@
html, html,
body { body {
@apply bg-denim-100 text-denim-700 font-open-sans overflow-x-hidden; @apply bg-denim-100 font-open-sans text-denim-700 overflow-x-hidden;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
} }
html[data-full], html[data-full] body { html[data-full],
html[data-full] body {
overscroll-behavior-y: none; overscroll-behavior-y: none;
} }

View File

@@ -55,11 +55,14 @@
"noVideos": "Whoops, couldn't find any videos for you", "noVideos": "Whoops, couldn't find any videos for you",
"loading": "Loading...", "loading": "Loading...",
"backToHome": "Back to home", "backToHome": "Back to home",
"backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}", "seasonAndEpisode": "S{{season}} E{{episode}}",
"buttons": { "buttons": {
"episodes": "Episodes", "episodes": "Episodes",
"source": "Source", "source": "Source",
"captions": "Captions" "captions": "Captions",
"download": "Download",
"pictureInPicture": "Picture in Picture"
}, },
"popouts": { "popouts": {
"sources": "Sources", "sources": "Sources",
@@ -68,6 +71,8 @@
"episode": "E{{index}} - {{title}}", "episode": "E{{index}} - {{title}}",
"noCaptions": "No captions", "noCaptions": "No captions",
"linkedCaptions": "Linked captions", "linkedCaptions": "Linked captions",
"customCaption": "Custom caption",
"uploadCustomCaption": "Upload caption (SRT, VTT)",
"noEmbeds": "No embeds were found for this source", "noEmbeds": "No embeds were found for this source",
"errors": { "errors": {
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}", "loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
@@ -87,5 +92,8 @@
}, },
"casting": { "casting": {
"casting": "Casting to device..." "casting": "Casting to device..."
},
"errors": {
"offline": "Check your internet connection"
} }
} }

View File

@@ -38,3 +38,11 @@ export function canWebkitFullscreen(): boolean {
export function canFullscreen(): boolean { export function canFullscreen(): boolean {
return canFullscreenAnyElement() || canWebkitFullscreen(); return canFullscreenAnyElement() || canWebkitFullscreen();
} }
export function canPictureInPicture(): boolean {
return "pictureInPictureEnabled" in document;
}
export function canWebkitPictureInPicture(): boolean {
return "webkitSupportsPresentationMode" in document.createElement("video");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
import { Icons } from "@/components/Icon";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
import { useControls } from "@/video/state/logic/controls";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useCallback } from "react";
import {
canPictureInPicture,
canWebkitPictureInPicture,
} from "@/utils/detectFeatures";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
interface Props {
className?: string;
}
export function PictureInPictureAction(props: Props) {
const { isMobile } = useIsMobile();
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const handleClick = useCallback(() => {
controls.togglePictureInPicture();
}, [controls]);
if (!canPictureInPicture() && !canWebkitPictureInPicture()) return null;
return (
<VideoPlayerIconButton
className={props.className}
icon={Icons.PICTURE_IN_PICTURE}
onClick={handleClick}
text={
isMobile ? (t("videoPlayer.buttons.pictureInPicture") as string) : ""
}
/>
);
}

View File

@@ -9,41 +9,53 @@ import {
import { AirplayAction } from "@/video/components/actions/AirplayAction"; import { AirplayAction } from "@/video/components/actions/AirplayAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction"; import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useBannerSize } from "@/hooks/useBanner";
interface VideoPlayerHeaderProps { interface VideoPlayerHeaderProps {
media?: MWMediaMeta; media?: MWMediaMeta;
onClick?: () => void; onClick?: () => void;
showControls?: boolean; showControls?: boolean;
isFullScreen?: boolean;
} }
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) { export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
const { isMobile } = useIsMobile();
const { bookmarkStore, setItemBookmark } = useBookmarkContext(); const { bookmarkStore, setItemBookmark } = useBookmarkContext();
const isBookmarked = props.media const isBookmarked = props.media
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media) ? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media)
: false; : false;
const showDivider = props.media && props.onClick; const showDivider = props.media && props.onClick;
const { t } = useTranslation(); const { t } = useTranslation();
const bannerHeight = useBannerSize();
return ( return (
<div className="flex items-center"> <div
<div className="flex flex-1 items-center"> className="flex items-center"
<p className="flex items-center"> style={{
paddingTop: props.isFullScreen ? `${bannerHeight}px` : undefined,
}}
>
<div className="flex min-w-0 flex-1 items-center">
<p className="flex items-center truncate">
{props.onClick ? ( {props.onClick ? (
<span <span
onClick={props.onClick} onClick={props.onClick}
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100" className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100"
> >
<Icon className="mr-2" icon={Icons.ARROW_LEFT} /> <Icon className="mr-2" icon={Icons.ARROW_LEFT} />
<span>{t("videoPlayer.backToHome")}</span> {isMobile ? (
<span>{t("videoPlayer.backToHomeShort")}</span>
) : (
<span>{t("videoPlayer.backToHome")}</span>
)}
</span> </span>
) : null} ) : null}
{showDivider ? ( {showDivider ? (
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" /> <span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
) : null} ) : null}
{props.media ? ( {props.media ? (
<span className="flex items-center text-white"> <span className="truncate text-white">{props.media.title}</span>
<span>{props.media.title}</span>
</span>
) : null} ) : null}
</p> </p>
{props.media && ( {props.media && (
@@ -64,7 +76,7 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
<ChromecastAction /> <ChromecastAction />
</> </>
) : ( ) : (
<BrandPill /> <BrandPill hideTextOnMobile />
)} )}
</div> </div>
); );

View File

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

View File

@@ -1,4 +1,8 @@
import { getCaptionUrl } from "@/backend/helpers/captions"; import {
getCaptionUrl,
convertCustomCaptionFileToWebVTT,
CUSTOM_CAPTION_ID,
} from "@/backend/helpers/captions";
import { MWCaption } from "@/backend/helpers/streams"; import { MWCaption } from "@/backend/helpers/streams";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { useLoading } from "@/hooks/useLoading"; import { useLoading } from "@/hooks/useLoading";
@@ -6,7 +10,7 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useMeta } from "@/video/state/logic/meta"; import { useMeta } from "@/video/state/logic/meta";
import { useSource } from "@/video/state/logic/source"; import { useSource } from "@/video/state/logic/source";
import { useMemo, useRef } from "react"; import { ChangeEvent, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
@@ -37,6 +41,29 @@ export function CaptionSelectionPopout() {
); );
const currentCaption = source.source?.caption?.id; const currentCaption = source.source?.caption?.id;
const customCaptionUploadElement = useRef<HTMLInputElement>(null);
const [setCustomCaption, loadingCustomCaption, errorCustomCaption] =
useLoading(async (captionFile: File) => {
if (
!captionFile.name.endsWith(".srt") &&
!captionFile.name.endsWith(".vtt")
) {
throw new Error("Only SRT or VTT files are allowed");
}
controls.setCaption(
CUSTOM_CAPTION_ID,
await convertCustomCaptionFileToWebVTT(captionFile)
);
controls.closePopout();
});
async function handleUploadCaption(e: ChangeEvent<HTMLInputElement>) {
if (!e.target.files) {
return;
}
const captionFile = e.target.files[0];
setCustomCaption(captionFile);
}
return ( return (
<> <>
@@ -54,6 +81,26 @@ export function CaptionSelectionPopout() {
> >
{t("videoPlayer.popouts.noCaptions")} {t("videoPlayer.popouts.noCaptions")}
</PopoutListEntry> </PopoutListEntry>
<PopoutListEntry
key={CUSTOM_CAPTION_ID}
active={currentCaption === CUSTOM_CAPTION_ID}
loading={loadingCustomCaption}
errored={!!errorCustomCaption}
onClick={() => {
customCaptionUploadElement.current?.click();
}}
>
{currentCaption === CUSTOM_CAPTION_ID
? t("videoPlayer.popouts.customCaption")
: t("videoPlayer.popouts.uploadCustomCaption")}
<input
ref={customCaptionUploadElement}
type="file"
onChange={handleUploadCaption}
className="hidden"
accept=".vtt, .srt"
/>
</PopoutListEntry>
</PopoutSection> </PopoutSection>
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-200 px-5 py-3 text-sm font-bold uppercase"> <p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-200 px-5 py-3 text-sm font-bold uppercase">

View File

@@ -5,6 +5,7 @@ import { SourceSelectionPopout } from "@/video/components/popouts/SourceSelectio
import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelectionPopout"; import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelectionPopout";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useIsMobile } from "@/hooks/useIsMobile";
import { import {
useInterface, useInterface,
VideoInterfaceEvent, VideoInterfaceEvent,
@@ -37,6 +38,8 @@ function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) {
const [bottom, setBottom] = useState<number>(0); const [bottom, setBottom] = useState<number>(0);
const [width, setWidth] = useState<number>(0); const [width, setWidth] = useState<number>(0);
const { isMobile } = useIsMobile(true);
const calculateAndSetCoords = useCallback((rect: DOMRect, w: number) => { const calculateAndSetCoords = useCallback((rect: DOMRect, w: number) => {
const buttonCenter = rect.left + rect.width / 2; const buttonCenter = rect.left + rect.width / 2;
@@ -57,7 +60,10 @@ function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) {
return ( return (
<div <div
ref={ref} ref={ref}
className="absolute z-10 grid h-[500px] w-80 grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200" className={[
"absolute z-10 grid w-80 grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200",
isMobile ? "h-[230px]" : " h-[500px]",
].join(" ")}
style={{ style={{
right: `${right}px`, right: `${right}px`,
bottom: `${bottom}px`, bottom: `${bottom}px`,

View File

@@ -96,7 +96,7 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
return ( return (
<div <div
className={[ className={[
"group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150", "group my-2 -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
hover, hover,
props.active props.active
? `${bg} active text-white outline-denim-700` ? `${bg} active text-white outline-denim-700`

View File

@@ -169,8 +169,6 @@ export function SourceSelectionPopout() {
return entries; return entries;
}); });
console.log(embedsRes);
return embedsRes; return embedsRes;
}, [scrapeResult?.embeds]); }, [scrapeResult?.embeds]);

View File

@@ -13,6 +13,7 @@ export type ControlMethods = {
setMeta(data?: VideoPlayerMeta): void; setMeta(data?: VideoPlayerMeta): void;
setCurrentEpisode(sId: string, eId: string): void; setCurrentEpisode(sId: string, eId: string): void;
setDraggingTime(num: number): void; setDraggingTime(num: number): void;
togglePictureInPicture(): void;
}; };
export function useControls( export function useControls(
@@ -100,5 +101,9 @@ export function useControls(
updateMeta(descriptor, state); updateMeta(descriptor, state);
} }
}, },
togglePictureInPicture() {
state.stateProvider?.togglePictureInPicture();
updateInterface(descriptor, state);
},
}; };
} }

View File

@@ -12,6 +12,7 @@ import {
} from "@/video/components/hooks/volumeStore"; } from "@/video/components/hooks/volumeStore";
import { resetStateForSource } from "@/video/state/providers/helpers"; import { resetStateForSource } from "@/video/state/providers/helpers";
import { updateInterface } from "@/video/state/logic/interface"; import { updateInterface } from "@/video/state/logic/interface";
import { revokeCaptionBlob } from "@/backend/helpers/captions";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { updateMediaPlaying } from "../logic/mediaplaying"; import { updateMediaPlaying } from "../logic/mediaplaying";
import { VideoPlayerStateProvider } from "./providerTypes"; import { VideoPlayerStateProvider } from "./providerTypes";
@@ -83,6 +84,9 @@ export function createCastingStateProvider(
state.pausedWhenSeeking = state.mediaPlaying.isPaused; state.pausedWhenSeeking = state.mediaPlaying.isPaused;
this.pause(); this.pause();
}, },
togglePictureInPicture() {
// no picture in picture while casting
},
async setVolume(v) { async setVolume(v) {
// clamp time between 0 and 1 // clamp time between 0 and 1
let volume = Math.min(v, 1); let volume = Math.min(v, 1);
@@ -135,6 +139,7 @@ export function createCastingStateProvider(
}, },
setCaption(id, url) { setCaption(id, url) {
if (state.source) { if (state.source) {
revokeCaptionBlob(state.source.caption?.url);
state.source.caption = { state.source.caption = {
id, id,
url, url,
@@ -144,6 +149,7 @@ export function createCastingStateProvider(
}, },
clearCaption() { clearCaption() {
if (state.source) { if (state.source) {
revokeCaptionBlob(state.source.caption?.url);
state.source.caption = null; state.source.caption = null;
updateSource(descriptor, state); updateSource(descriptor, state);
} }

View File

@@ -19,6 +19,7 @@ export type VideoPlayerStateController = {
setCaption(id: string, url: string): void; setCaption(id: string, url: string): void;
clearCaption(): void; clearCaption(): void;
getId(): string; getId(): string;
togglePictureInPicture(): void;
}; };
export type VideoPlayerStateProvider = VideoPlayerStateController & { export type VideoPlayerStateProvider = VideoPlayerStateController & {

View File

@@ -5,6 +5,8 @@ import {
canFullscreen, canFullscreen,
canFullscreenAnyElement, canFullscreenAnyElement,
canWebkitFullscreen, canWebkitFullscreen,
canPictureInPicture,
canWebkitPictureInPicture,
} from "@/utils/detectFeatures"; } from "@/utils/detectFeatures";
import { MWStreamType } from "@/backend/helpers/streams"; import { MWStreamType } from "@/backend/helpers/streams";
import { updateInterface } from "@/video/state/logic/interface"; import { updateInterface } from "@/video/state/logic/interface";
@@ -16,6 +18,7 @@ import {
import { updateError } from "@/video/state/logic/error"; import { updateError } from "@/video/state/logic/error";
import { updateMisc } from "@/video/state/logic/misc"; import { updateMisc } from "@/video/state/logic/misc";
import { resetStateForSource } from "@/video/state/providers/helpers"; import { resetStateForSource } from "@/video/state/providers/helpers";
import { revokeCaptionBlob } from "@/backend/helpers/captions";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { updateMediaPlaying } from "../logic/mediaplaying"; import { updateMediaPlaying } from "../logic/mediaplaying";
import { VideoPlayerStateProvider } from "./providerTypes"; import { VideoPlayerStateProvider } from "./providerTypes";
@@ -191,6 +194,7 @@ export function createVideoStateProvider(
}, },
setCaption(id, url) { setCaption(id, url) {
if (state.source) { if (state.source) {
revokeCaptionBlob(state.source.caption?.url);
state.source.caption = { state.source.caption = {
id, id,
url, url,
@@ -200,10 +204,28 @@ export function createVideoStateProvider(
}, },
clearCaption() { clearCaption() {
if (state.source) { if (state.source) {
revokeCaptionBlob(state.source.caption?.url);
state.source.caption = null; state.source.caption = null;
updateSource(descriptor, state); updateSource(descriptor, state);
} }
}, },
togglePictureInPicture() {
if (canWebkitPictureInPicture()) {
const webkitPlayer = player as any;
webkitPlayer.webkitSetPresentationMode(
webkitPlayer.webkitPresentationMode === "picture-in-picture"
? "inline"
: "picture-in-picture"
);
}
if (canPictureInPicture()) {
if (player !== document.pictureInPictureElement) {
player.requestPictureInPicture();
} else {
document.exitPictureInPicture();
}
}
},
providerStart() { providerStart() {
this.setVolume(getStoredVolume()); this.setVolume(getStoredVolume());

View File

@@ -0,0 +1,26 @@
import { Navigation } from "@/components/layout/Navigation";
import { ThinContainer } from "@/components/layout/ThinContainer";
import { ArrowLink } from "@/components/text/ArrowLink";
import { Title } from "@/components/text/Title";
export function DeveloperView() {
return (
<div className="py-48">
<Navigation />
<ThinContainer classNames="flex flex-col space-y-4">
<Title className="mb-8">Developer tools</Title>
<ArrowLink
to="/dev/providers"
direction="right"
linkText="Provider tester"
/>
<ArrowLink
to="/dev/embeds"
direction="right"
linkText="Embed scraper tester"
/>
<ArrowLink to="/dev/video" direction="right" linkText="Video tester" />
</ThinContainer>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { MWEmbed, MWEmbedScraper, MWEmbedType } from "@/backend/helpers/embed";
import { getEmbeds } from "@/backend/helpers/register";
import { runEmbedScraper } from "@/backend/helpers/run";
import { MWStream } from "@/backend/helpers/streams";
import { Button } from "@/components/Button";
import { Navigation } from "@/components/layout/Navigation";
import { ArrowLink } from "@/components/text/ArrowLink";
import { Title } from "@/components/text/Title";
import { useLoading } from "@/hooks/useLoading";
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
interface MediaSelectorProps {
embedType: MWEmbedType;
onSelect: (meta: MWEmbed) => void;
}
interface EmbedScraperSelectorProps {
onSelect: (embedScraperId: string) => void;
}
interface MediaScraperProps {
embed: MWEmbed;
scraper: MWEmbedScraper;
}
function MediaSelector(props: MediaSelectorProps) {
const [url, setUrl] = useState("");
const select = useCallback(
(urlSt: string) => {
props.onSelect({
type: props.embedType,
url: urlSt,
});
},
[props]
);
return (
<div className="flex flex-col space-y-4">
<Title className="mb-8">Input embed url</Title>
<div className="mb-4 flex gap-4">
<input
type="text"
placeholder="embed url here..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Button onClick={() => select(url)}>Run scraper</Button>
</div>
</div>
);
}
function MediaScraper(props: MediaScraperProps) {
const [results, setResults] = useState<MWStream | null>(null);
const [percentage, setPercentage] = useState(0);
const [scrape, loading, error] = useLoading(async (url: string) => {
const data = await runEmbedScraper(props.scraper, {
url,
progress(num) {
console.log(`SCRAPING AT ${num}%`);
setPercentage(num);
},
});
console.log("got data", data);
setResults(data);
});
useEffect(() => {
if (props.embed) {
scrape(props.embed.url);
}
}, [props.embed, scrape]);
if (loading) return <p>Scraping... ({percentage}%)</p>;
if (error) return <p>Errored, check console</p>;
return (
<div>
<Title className="mb-8">Output data</Title>
<code>
<pre>{JSON.stringify(results, null, 2)}</pre>
</code>
</div>
);
}
function EmbedScraperSelector(props: EmbedScraperSelectorProps) {
const embedScrapers = getEmbeds();
return (
<div className="flex flex-col space-y-4">
<Title className="mb-8">Choose embed scraper</Title>
{embedScrapers.map((v) => (
<ArrowLink
key={v.id}
onClick={() => props.onSelect(v.id)}
direction="right"
linkText={v.displayName}
/>
))}
</div>
);
}
export function EmbedTesterView() {
const [embed, setEmbed] = useState<MWEmbed | null>(null);
const [embedScraperId, setEmbedScraperId] = useState<string | null>(null);
const embedScraper = useMemo(
() => getEmbeds().find((v) => v.id === embedScraperId),
[embedScraperId]
);
let content: ReactNode = null;
if (!embedScraperId || !embedScraper) {
content = <EmbedScraperSelector onSelect={(id) => setEmbedScraperId(id)} />;
} else if (!embed) {
content = (
<MediaSelector
embedType={embedScraper.for}
onSelect={(v) => setEmbed(v)}
/>
);
} else {
content = <MediaScraper scraper={embedScraper} embed={embed} />;
}
return (
<div className="py-48">
<Navigation />
<div className="mx-8 overflow-x-auto">{content}</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
import { getProviders } from "@/backend/helpers/register";
import { runProvider } from "@/backend/helpers/run";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { Navigation } from "@/components/layout/Navigation";
import { ArrowLink } from "@/components/text/ArrowLink";
import { Title } from "@/components/text/Title";
import { useLoading } from "@/hooks/useLoading";
import { testData } from "@/__tests__/providers/testdata";
import { ReactNode, useEffect, useState } from "react";
interface MediaSelectorProps {
onSelect: (meta: DetailedMeta) => void;
}
interface ProviderSelectorProps {
onSelect: (providerId: string) => void;
}
interface MediaScraperProps {
media: DetailedMeta | null;
id: string;
}
function MediaSelector(props: MediaSelectorProps) {
const options: DetailedMeta[] = testData;
return (
<div className="flex flex-col space-y-4">
<Title className="mb-8">Choose media</Title>
{options.map((v) => (
<ArrowLink
key={v.imdbId}
onClick={() => props.onSelect(v)}
direction="right"
linkText={`${v.meta.title} (${v.meta.type})`}
/>
))}
</div>
);
}
function MediaScraper(props: MediaScraperProps) {
const [results, setResults] = useState<MWProviderScrapeResult | null>(null);
const [percentage, setPercentage] = useState(0);
const [scrape, loading, error] = useLoading(async (media: DetailedMeta) => {
const provider = getProviders().find((v) => v.id === props.id);
if (!provider) throw new Error("provider not found");
const data = await runProvider(provider, {
progress(num) {
console.log(`SCRAPING AT ${num}%`);
setPercentage(num);
},
media,
type: media.meta.type as any,
});
console.log("got data", data);
setResults(data);
});
useEffect(() => {
if (props.media) {
scrape(props.media);
}
}, [props.media, scrape]);
if (loading) return <p>Scraping... ({percentage}%)</p>;
if (error) return <p>Errored, check console</p>;
return (
<div>
<Title className="mb-8">Output data</Title>
<code>
<pre>{JSON.stringify(results, null, 2)}</pre>
</code>
</div>
);
}
function ProviderSelector(props: ProviderSelectorProps) {
const providers = getProviders();
return (
<div className="flex flex-col space-y-4">
<Title className="mb-8">Choose provider</Title>
{providers.map((v) => (
<ArrowLink
key={v.id}
onClick={() => props.onSelect(v.id)}
direction="right"
linkText={v.displayName}
/>
))}
</div>
);
}
export function ProviderTesterView() {
const [media, setMedia] = useState<DetailedMeta | null>(null);
const [providerId, setProviderId] = useState<string | null>(null);
let content: ReactNode = null;
if (!providerId) {
content = <ProviderSelector onSelect={(id) => setProviderId(id)} />;
} else if (!media) {
content = <MediaSelector onSelect={(v) => setMedia(v)} />;
} else {
content = <MediaScraper id={providerId} media={media} />;
}
return (
<div className="py-48">
<Navigation />
<div className="mx-8 overflow-x-auto">{content}</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types";
import { Button } from "@/components/Button";
import { Dropdown } from "@/components/Dropdown";
import { Navigation } from "@/components/layout/Navigation";
import { ThinContainer } from "@/components/layout/ThinContainer";
import { MetaController } from "@/video/components/controllers/MetaController";
import { SourceController } from "@/video/components/controllers/SourceController";
import { VideoPlayer } from "@/video/components/VideoPlayer";
import { useCallback, useState } from "react";
import { Helmet } from "react-helmet";
interface VideoData {
streamUrl: string;
type: MWStreamType;
}
const testData: VideoData = {
streamUrl:
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
type: MWStreamType.MP4,
};
const testMeta: DetailedMeta = {
imdbId: "",
tmdbId: "",
meta: {
id: "hello-world",
title: "Big Buck Bunny",
type: MWMediaType.MOVIE,
seasons: undefined,
year: "2000",
},
};
export function VideoTesterView() {
const [video, setVideo] = useState<VideoData | null>(null);
const [videoType, setVideoType] = useState<MWStreamType>(MWStreamType.MP4);
const [url, setUrl] = useState("");
const playVideo = useCallback(
(streamUrl: string) => {
setVideo({
streamUrl,
type: videoType,
});
},
[videoType]
);
if (video) {
return (
<div className="fixed top-0 left-0 h-[100dvh] w-screen">
<Helmet>
<html data-full="true" />
</Helmet>
<VideoPlayer includeSafeArea autoPlay onGoBack={() => setVideo(null)}>
<MetaController
data={{
captions: [],
meta: testMeta,
}}
linkedCaptions={[]}
/>
<SourceController
source={video.streamUrl}
type={MWStreamType.MP4}
quality={MWStreamQuality.Q720P}
/>
</VideoPlayer>
</div>
);
}
return (
<div className="py-64">
<Navigation />
<ThinContainer classNames="flex items-start flex-col space-y-4">
<div className="w-48">
<Dropdown
options={[
{ id: MWStreamType.MP4, name: "Mp4" },
{ id: MWStreamType.HLS, name: "hls/m3u8" },
]}
selectedItem={{ id: videoType, name: videoType }}
setSelectedItem={(a) => setVideoType(a.id as MWStreamType)}
/>
</div>
<div className="mb-4 flex gap-4">
<input
type="text"
placeholder="stream url here..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Button onClick={() => playVideo(url)}>Play video</Button>
</div>
<Button
onClick={() =>
setVideo({
streamUrl: testData.streamUrl,
type: testData.type,
})
}
>
Play default video
</Button>
</ThinContainer>
</div>
);
}

View File

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

View File

@@ -28,11 +28,11 @@ function MediaViewLoading(props: { onGoBack(): void }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="relative flex h-screen items-center justify-center"> <div className="relative flex flex-1 items-center justify-center">
<Helmet> <Helmet>
<title>{t("videoPlayer.loading")}</title> <title>{t("videoPlayer.loading")}</title>
</Helmet> </Helmet>
<div className="absolute inset-x-0 top-0 p-6"> <div className="absolute inset-x-0 top-0 py-6 px-8">
<VideoPlayerHeader onClick={props.onGoBack} /> <VideoPlayerHeader onClick={props.onGoBack} />
</div> </div>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@@ -62,7 +62,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
}, [stream, props]); }, [stream, props]);
return ( return (
<div className="relative flex h-screen items-center justify-center"> <div className="relative flex flex-1 items-center justify-center">
<Helmet> <Helmet>
<title>{props.meta.meta.title}</title> <title>{props.meta.meta.title}</title>
</Helmet> </Helmet>

View File

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

View File

@@ -11,6 +11,52 @@ function fromBinary(str: string): Uint8Array {
return result; return result;
} }
export function importV2Data({ data, time }: { data: any; time: Date }) {
const savedTime = localStorage.getItem("mw-migration-date");
if (savedTime) {
if (new Date(savedTime) >= time) {
// has already migrated this or something newer, skip
return false;
}
}
// restore migration data
if (data.bookmarks)
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks));
if (data.videoProgress)
localStorage.setItem("video-progress", JSON.stringify(data.videoProgress));
localStorage.setItem("mw-migration-date", time.toISOString());
return true;
}
export function EmbedMigration() {
let hasReceivedMigrationData = false;
const onMessage = (e: any) => {
const data = e.data;
if (data && data.isMigrationData && !hasReceivedMigrationData) {
hasReceivedMigrationData = true;
const didImport = importV2Data({
data: data.data,
time: data.date,
});
if (didImport) window.location.reload();
}
};
useEffect(() => {
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
};
});
return <iframe src="https://movie.squeezebox.dev" hidden />;
}
export function V2MigrationView() { export function V2MigrationView() {
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
useEffect(() => { useEffect(() => {
@@ -28,24 +74,10 @@ export function V2MigrationView() {
); );
const timeOfMigration = new Date(params.get("m-time") as string); const timeOfMigration = new Date(params.get("m-time") as string);
const savedTime = localStorage.getItem("mw-migration-date"); importV2Data({
if (savedTime) { data,
if (new Date(savedTime) >= timeOfMigration) { time: timeOfMigration,
// has already migrated this or something newer, skip });
setDone(true);
return;
}
}
// restore migration data
if (data.bookmarks)
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks));
if (data.videoProgress)
localStorage.setItem(
"video-progress",
JSON.stringify(data.videoProgress)
);
localStorage.setItem("mw-migration-date", timeOfMigration.toISOString());
// finished // finished
setDone(true); setDone(true);

View File

@@ -14,6 +14,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { Modal, ModalCard } from "@/components/layout/Modal"; import { Modal, ModalCard } from "@/components/layout/Modal";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { EmbedMigration } from "../other/v2Migration";
function Bookmarks() { function Bookmarks() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -171,7 +172,8 @@ function NewDomainModal() {
export function HomeView() { export function HomeView() {
return ( return (
<div className="mb-16 mt-32"> <div className="mb-16">
<EmbedMigration />
<NewDomainModal /> <NewDomainModal />
<Bookmarks /> <Bookmarks />
<Watched /> <Watched />

View File

@@ -7,6 +7,7 @@ import { SearchBarInput } from "@/components/SearchBar";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { useSearchQuery } from "@/hooks/useSearchQuery"; import { useSearchQuery } from "@/hooks/useSearchQuery";
import { WideContainer } from "@/components/layout/WideContainer"; import { WideContainer } from "@/components/layout/WideContainer";
import { useBannerSize } from "@/hooks/useBanner";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { SearchResultsPartial } from "./SearchResultsPartial"; import { SearchResultsPartial } from "./SearchResultsPartial";
@@ -14,6 +15,7 @@ export function SearchView() {
const { t } = useTranslation(); const { t } = useTranslation();
const [search, setSearch, setSearchUnFocus] = useSearchQuery(); const [search, setSearch, setSearchUnFocus] = useSearchQuery();
const [showBg, setShowBg] = useState(false); const [showBg, setShowBg] = useState(false);
const bannerSize = useBannerSize();
const stickStateChanged = useCallback( const stickStateChanged = useCallback(
({ status }: Sticky.Status) => setShowBg(status === Sticky.STATUS_FIXED), ({ status }: Sticky.Status) => setShowBg(status === Sticky.STATUS_FIXED),
@@ -22,7 +24,7 @@ export function SearchView() {
return ( return (
<> <>
<div className="relative z-10 mb-24"> <div className="relative z-10 mb-16 sm:mb-24">
<Helmet> <Helmet>
<title>{t("global.name")}</title> <title>{t("global.name")}</title>
</Helmet> </Helmet>
@@ -32,11 +34,15 @@ export function SearchView() {
<div className="absolute left-0 bottom-0 right-0 flex h-0 justify-center"> <div className="absolute left-0 bottom-0 right-0 flex h-0 justify-center">
<div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" /> <div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" />
</div> </div>
<div className="relative z-20"> <div className="relative z-10 mb-16">
<div className="mb-16"> <Title className="mx-auto max-w-xs">{t("search.title")}</Title>
<Title className="mx-auto max-w-xs">{t("search.title")}</Title> </div>
</div> <div className="relative z-30">
<Sticky enabled top={16} onStateChange={stickStateChanged}> <Sticky
enabled
top={16 + bannerSize}
onStateChange={stickStateChanged}
>
<SearchBarInput <SearchBarInput
onChange={setSearch} onChange={setSearch}
value={search} value={search}

View File

@@ -19,7 +19,7 @@
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]
}, },
"types": ["vite/client"] "types": ["vite/client", "vite-plugin-pwa/client"]
}, },
"include": ["src"] "include": ["src"]
} }

View File

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

2734
yarn.lock

File diff suppressed because it is too large Load Diff