Compare commits

...

146 Commits

Author SHA1 Message Date
mrjvs
3ed5dcfc15 Merge pull request #271 from movie-web/dev
v3.0.12
2023-04-21 21:17:27 +02:00
mrjvs
71235f5174 Merge branch 'master' into dev 2023-04-21 21:16:06 +02:00
mrjvs
0d79a677a0 Merge pull request #270 from movie-web/jvs-sentry-telemetry
Sentry telemetry
2023-04-21 21:15:14 +02:00
mrjvs
a34d245e2b version bump 2023-04-21 21:09:56 +02:00
mrjvs
8b8cbc8cc9 Dutch language translations
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-04-21 21:08:01 +02:00
mrjvs
5ee4f013ff Sentry integration
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-04-21 20:49:47 +02:00
mrjvs
99a3e6db69 Merge pull request #268 from movie-web/dev
V3.0.11
2023-04-20 21:31:55 +02:00
mrjvs
7d3e1c0943 Merge branch 'master' into dev 2023-04-20 21:30:03 +02:00
mrjvs
2cfd7e64a2 remove gdrive from bundle 2023-04-20 21:29:47 +02:00
mrjvs
d6def996bf bump version 2023-04-20 21:26:37 +02:00
mrjvs
8bba2961b4 Merge pull request #266 from Jordaar/dev
Add hdwatched provider
2023-04-20 21:24:47 +02:00
mrjvs
da05a2597e Merge branch 'dev' into dev 2023-04-20 21:11:24 +02:00
mrjvs
d40076e950 Merge pull request #267 from JipFr/dev
Add volume adjusted bar for keyboard events, fix UI always being dismissed after single mousemove
2023-04-20 21:09:42 +02:00
mrjvs
bb4a6d8a1e Merge branch 'dev' into dev 2023-04-20 21:08:28 +02:00
Jip Fr
7007f030e1 feat(player): use state-specific debouncer, not global 2023-04-20 21:07:44 +02:00
mrjvs
24fa1c449f Merge pull request #255 from zisra/movie-time
Time format
2023-04-20 21:04:13 +02:00
mrjvs
591b1d3bc5 Merge branch 'dev' into movie-time 2023-04-20 20:57:20 +02:00
mrjvs
c162f15496 Merge pull request #252 from frost768/settings
A settings modal
2023-04-20 20:56:55 +02:00
mrjvs
2650707d2c Merge branch 'dev' into movie-time 2023-04-20 20:54:42 +02:00
Jip Fr
a0a51c898a chore: remove unused import 2023-04-20 20:53:35 +02:00
mrjvs
43c8da9003 remove unsused useControls 2023-04-20 20:53:23 +02:00
mrjvs
1472b21600 negative sign thingy 2023-04-20 20:52:06 +02:00
Jip Fr
2424cdfc9e feat(video): add "volume adjusted" bar on top for keyboard events 2023-04-20 20:51:05 +02:00
frost768
2239c186a5 modal background changed 2023-04-20 21:43:51 +03:00
Jip Fr
0c2df2cd3c fix(player): fix dismissal of UI after only 1 mousemove event 2023-04-20 19:50:57 +02:00
JORDAAR
b26b0715bd increase rank 2023-04-20 22:26:54 +05:30
JORDAAR
7b75c36d21 add series support & improvements 2023-04-20 15:53:28 +05:30
JORDAAR
e52b29a1a1 add hdwatched provider 2023-04-19 15:44:20 +05:30
frost768
12c245b2da Merge branch 'dev' of https://github.com/movie-web/movie-web into settings 2023-04-15 01:00:11 +03:00
mrjvs
871780f95e Merge pull request #261 from movie-web/dev
version 3.0.10
2023-04-14 22:35:57 +02:00
mrjvs
fa985fc2c2 Merge branch 'master' into dev 2023-04-14 22:35:02 +02:00
mrjvs
db9eec195a bump version 2023-04-14 22:32:45 +02:00
mrjvs
de1221235b Merge pull request #260 from JipFr/dev
A couple bug fixes
2023-04-14 21:44:46 +02:00
Jip Fr
b576a298e8 Disable netfilm 2023-04-14 21:43:30 +02:00
Jip Frijlink
fcb24c783c Update src/components/popout/positions/FloatingCardMobilePosition.tsx 2023-04-14 21:40:55 +02:00
c5251401e7 Does this fix it? 2023-04-14 14:18:17 -05:00
41fd23cf20 Reviews 2023-04-14 14:11:13 -05:00
Jip Fr
5dfeeadbb8 fix(popouts): fix touch on scroll areas being weird 2023-04-14 20:03:11 +02:00
Jip Fr
0794558338 fix(player): add max-height to modals for smaller screens 2023-04-14 19:39:01 +02:00
Jip Fr
d2ffa35f2c fix(superstream): fix subtitle error on SuperStream 2023-04-14 19:32:34 +02:00
c330112dbc Translations 2023-04-11 16:34:19 -05:00
84b8a67cea Time format 2023-04-11 16:16:06 -05:00
frost768
546b008b2e show text when no caption language is selected 2023-04-10 22:10:11 +03:00
frost768
b9b0380dfe suggested changes 2023-04-10 00:55:23 +03:00
frost768
c472e7f7b8 Merge branch 'dev' of https://github.com/movie-web/movie-web into settings 2023-04-09 23:22:32 +03:00
mrjvs
3decc9190c Merge pull request #224 from zisra/dev
Playback speed
2023-04-09 13:22:12 +02:00
zisra
184af19498 Merge branch 'movie-web:dev' into dev 2023-04-07 23:15:05 -05:00
frost768
2eab07b8b6 modal customization 2023-04-06 04:35:20 +03:00
frost768
5d8f03b859 fix migration 2023-04-06 04:34:59 +03:00
frost768
2178057633 auto select subtitle 2023-04-06 01:49:33 +03:00
frost768
9e961223f6 settings modal 2023-04-06 01:48:07 +03:00
frost768
c2b52d3db8 Add language selection 2023-04-06 01:46:27 +03:00
zisra
06a44da9cc Update index.tsx 2023-04-02 10:29:44 -05:00
zisra
49d7dc9761 Update VideoErrorBoundary.tsx 2023-04-02 10:28:20 -05:00
mrjvs
1585805d86 Merge pull request #230 from frost768/subtitle-fix
Better subtitle handling
2023-04-02 17:21:28 +02:00
frost768
7dc76e993f Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-fix 2023-04-02 18:14:44 +03:00
frost768
661d995e3b filter out non subtitle files 2023-04-02 18:14:26 +03:00
frost768
156b693460 suggested changes 2023-04-02 18:14:03 +03:00
mrjvs
d82b32e8d9 Merge branch 'dev' into dev 2023-04-02 17:05:02 +02:00
zisra
8a8dbb2778 Update yarn.lock 2023-04-02 10:03:54 -05:00
zisra
6d95f83c0b Update videoStateProvider.ts 2023-04-02 10:01:52 -05:00
mrjvs
2fe53a05e8 Merge pull request #231 from frost768/exclude-dev-routes
Exclude dev routes from production
2023-04-02 16:46:57 +02:00
frost768
495222eb10 export subtitle types as a list 2023-04-01 12:19:05 +03:00
zisra
119bafa516 Update translation.json 2023-03-31 16:03:47 -05:00
frost768
ba1ee0267b Merge branch 'dev' of https://github.com/movie-web/movie-web into exclude-dev-routes 2023-03-31 21:11:44 +03:00
frost768
92ef687ddc change: use ternary instead of short circuit 2023-03-31 21:07:58 +03:00
frost768
5e776f8655 Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-fix 2023-03-31 20:57:18 +03:00
zisra
c541d4212a Merge branch 'dev' into dev 2023-03-30 21:19:21 -05:00
2d17c8abaa Remove duplicate label 2023-03-30 19:10:35 -05:00
zisra
4a52fc11ed Keyboard up and down 2023-03-30 23:25:49 +00:00
zisra
54d1af0e0a Suggested changes 2023-03-30 23:21:17 +00:00
mrjvs
48f54dd7cc Merge pull request #243 from JipFr/dev
Add volume up / down keyboard events
2023-03-31 00:56:31 +02:00
mrjvs
3a44eb550d Merge branch 'dev' into dev 2023-03-31 00:55:05 +02:00
Jip Fr
0fa3d3e430 fix: copy over old yarn.lock 2023-03-31 00:52:14 +02:00
mrjvs
a9849b40c2 Merge pull request #232 from frost768/flixhq-filter
Flixhq media type filter
2023-03-31 00:36:32 +02:00
Jip Fr
80954514b6 chore(player): add comments to up/down kb events 2023-03-30 19:54:06 +02:00
Jip Fr
e2dd74c0af feat(player): add arrow up/down controls for volume 2023-03-30 19:53:27 +02:00
frost768
2f10de415b add flixhq media type filter 2023-03-26 10:44:16 +03:00
frost768
efcb12f95a exclude dev routes from production 2023-03-26 10:41:39 +03:00
frost768
307f555b70 better subtitle handling 2023-03-26 10:33:24 +03:00
frost768
4d5f03337d Merge branch 'dev' of https://github.com/movie-web/movie-web into dev 2023-03-26 00:24:27 +03:00
mrjvs
9f008f02d1 Merge pull request #229 from movie-web/dev
Version 3.0.9
2023-03-25 22:12:20 +01:00
mrjvs
e91f65dd91 Merge branch 'master' into dev 2023-03-25 22:11:54 +01:00
mrjvs
3aab008f12 version bump 2023-03-25 22:10:51 +01:00
mrjvs
659b0168c3 Merge pull request #228 from Artikronisz/hotfix/20230325_FlixHQ_Fix
Fix for flixHQ provider
2023-03-25 22:03:00 +01:00
mrjvs
e9e2129aa2 Merge branch 'dev' into hotfix/20230325_FlixHQ_Fix 2023-03-25 22:01:22 +01:00
mrjvs
bed3318ebe Merge pull request #226 from judemont/dev
Add French translation
2023-03-25 21:53:31 +01:00
Artikronisz
436a2388b9 Fix for flixHQ provider 2023-03-25 16:20:50 -04:00
judemont
1ad1c69d3e Add french translation 2023-03-24 21:55:03 +01:00
zisra
fac2b50bfc Reset config 2023-03-23 14:19:18 -05:00
zisra
4d08ecc694 Playback speed 2023-03-23 13:52:34 -05:00
frost768
5edc99cdfe Merge branch 'dev' of https://github.com/movie-web/movie-web into dev 2023-03-23 01:33:49 +03:00
mrjvs
3b0232b3d6 Merge pull request #221 from movie-web/dev
Version 3.0.8
2023-03-22 22:51:23 +01:00
mrjvs
f2ea05708f bump version again 🎉 2023-03-22 22:49:18 +01:00
mrjvs
772777835e Merge branch 'master' into dev 2023-03-22 22:47:55 +01:00
mrjvs
dc58c2b55e bump version 2023-03-22 22:44:36 +01:00
mrjvs
c7f3f774bb Merge pull request #218 from movie-web/variety-fixes
Variety of fixes
2023-03-22 22:41:39 +01:00
mrjvs
96656d9a2f fix progress range margins 2023-03-22 22:38:08 +01:00
mrjvs
5419430369 fix pokemon error 2023-03-22 22:31:23 +01:00
frost768
603e42b907 remove unnecessary margin from slider 2023-03-22 12:51:51 +03:00
frost768
d51603a382 fix safari fullscreen 2023-03-22 12:38:12 +03:00
mrjvs
731ef6a9aa fix type errors (sort of) 2023-03-19 20:53:44 +01:00
mrjvs
0de9551080 regenerate lock file 2023-03-19 20:29:07 +01:00
mrjvs
0f7c51c198 Merge branch 'dev' into variety-fixes 2023-03-19 20:27:28 +01:00
mrjvs
cf2060bd32 Merge pull request #185 from frost768/feat/subtitle-rendering
Subtitle rendering feature added
2023-03-19 20:25:23 +01:00
mrjvs
ec73d5ef90 fix linting 2023-03-19 20:25:05 +01:00
mrjvs
9c159f01bd remove lint annotations 2023-03-19 20:22:44 +01:00
mrjvs
215b5920c3 fix checkmark styling 2023-03-19 20:20:17 +01:00
mrjvs
6136ff92e6 code cleanup 2023-03-19 20:19:21 +01:00
mrjvs
51dfef18fb cleanup caption cues 2023-03-19 20:10:18 +01:00
mrjvs
12f7f2ee03 fix modal routing 2023-03-19 20:00:56 +01:00
mrjvs
01f46ce23c fine-tune caption rendering 2023-03-19 19:58:30 +01:00
mrjvs
ffe817388a scrollToActive fixed 2023-03-19 19:10:56 +01:00
mrjvs
37d5aaede9 add z-index 0 to video element 2023-03-19 18:36:52 +01:00
mrjvs
e2b1a9bfde fix babel imports and fix package warnings 2023-03-19 18:32:04 +01:00
mrjvs
827d4b576b babel (old browser support) + pwa cache refreshing 2023-03-19 18:01:08 +01:00
frost768
5664540acc last touches to design 2023-03-19 16:17:53 +03:00
frost768
4fe7f1fd1c fs.realpath dependency yarn.lock fix 2023-03-17 20:25:51 +03:00
frost768
12555a5933 remove parent span 2023-03-16 22:10:45 +03:00
frost768
9fe7bdcf47 change sub render positioning to absolute 2023-03-16 21:35:22 +03:00
frost768
20addc039c Merge branch 'feat/subtitle-rendering' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-15 17:54:27 +03:00
frost768
9dad4e687d Merge branch 'dev' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-15 17:54:21 +03:00
Emre Can Minnet
870aa4f105 Merge branch 'movie-web:dev' into feat/subtitle-rendering 2023-03-15 17:54:00 +03:00
frost768
464b78d914 add caption settings popout 2023-03-15 17:48:50 +03:00
frost768
06d043d482 settings view removed 2023-03-15 17:47:50 +03:00
mrjvs
01f98c583a Merge pull request #214 from frost768/dev
fix flixhq episodeId
2023-03-14 23:58:29 +01:00
frost768
f0c9103e0d Merge branch 'dev' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-14 23:54:59 +03:00
frost768
53a0168615 Merge branch 'dev' of https://github.com/movie-web/movie-web into dev 2023-03-14 23:38:06 +03:00
frost768
c9ccf018f2 fix flixhq episodeId 2023-03-14 23:29:39 +03:00
mrjvs
fec1d5ac15 Merge pull request #213 from movie-web/dev
v3.0.7: hotfix for fullscreen popouts
2023-03-14 21:12:54 +01:00
mrjvs
9bedf2b9f1 Merge branch 'master' into dev 2023-03-14 21:11:20 +01:00
mrjvs
57ac2ac677 Merge pull request #212 from movie-web/hotfix-fullscreen-popouts
Hotfix: popouts in body instead of video
2023-03-14 21:10:35 +01:00
mrjvs
60a5f84f2f fix popouts in body instead of video 2023-03-14 21:02:47 +01:00
frost768
f2efd828dc forgot package.json, damnit 2023-03-13 23:25:42 +03:00
frost768
8e79e3acdb yarn.lock 2023-03-13 23:08:55 +03:00
frost768
31cd4d3c75 Merge branch 'dev' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-13 22:45:14 +03:00
frost768
dfe1dd53b7 Merge branch 'dev' of https://github.com/frost768/movie-web into dev 2023-03-13 22:31:20 +03:00
frost768
c2d09566b0 ok vite 2023-03-13 21:50:31 +03:00
frost768
3bee46ff53 sanitize html before placing into dom 2023-03-11 05:39:06 +03:00
frost768
315c3de3ab Merge branch 'dev' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-11 01:12:15 +03:00
Emre Can Minnet
007375c1df Merge branch 'dev' into feat/subtitle-rendering 2023-03-10 22:27:30 +03:00
frost768
bd26ed5bc0 fix background color alpha 2023-03-09 21:27:07 +03:00
frost768
ef4cb064e7 add caption settings popout 2023-03-09 20:09:48 +03:00
frost768
875be16c4c add subtitle renderer and remove track element 2023-03-09 20:09:22 +03:00
frost768
f264457c57 add settings context 2023-03-09 20:08:13 +03:00
frost768
7bf1d05f16 add node-webvtt for parsing subtitles 2023-03-09 20:06:34 +03:00
74 changed files with 4844 additions and 1600 deletions

View File

@@ -1,48 +0,0 @@
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

@@ -25,15 +25,8 @@ jobs:
- name: Install Yarn packages
run: yarn install
- name: Run ESLint Report
run: yarn lint:report
# continue on error, so it still reports it in the next step
continue-on-error: true
- uses: actions/upload-artifact@v3
with:
name: eslint_report.json
path: eslint_report.json
- name: Run ESLint
run: yarn lint
building:
name: Build project

View File

@@ -1,14 +1,18 @@
{
"name": "movie-web",
"version": "3.0.6",
"version": "3.0.12",
"private": true,
"homepage": "https://movie.squeezebox.dev",
"homepage": "https://movie-web.app",
"dependencies": {
"@formkit/auto-animate": "^1.0.0-beta.5",
"@headlessui/react": "^1.5.0",
"@react-spring/web": "^9.7.1",
"@sentry/integrations": "^7.49.0",
"@sentry/react": "^7.49.0",
"@use-gesture/react": "^10.2.24",
"core-js": "^3.29.1",
"crypto-js": "^4.1.1",
"dompurify": "^3.0.1",
"fscreen": "^1.2.0",
"fuse.js": "^6.4.6",
"hls.js": "^1.0.7",
@@ -28,7 +32,7 @@
"react-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5",
"react-use": "^17.4.0",
"srt-webvtt": "^2.0.0",
"subsrt-ts": "^2.1.0",
"unpacker": "^1.0.1"
},
"scripts": {
@@ -42,9 +46,8 @@
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
"defaults",
"chrome > 90"
],
"development": [
"last 1 chrome version",
@@ -53,22 +56,27 @@
]
},
"devDependencies": {
"@babel/core": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.21.0",
"@tailwindcss/line-clamp": "^0.4.2",
"@types/chromecast-caf-sender": "^1.0.5",
"@types/crypto-js": "^4.1.1",
"@types/dompurify": "^2.4.0",
"@types/fscreen": "^1.0.1",
"@types/lodash.throttle": "^4.1.7",
"@types/node": "^17.0.15",
"@types/pako": "^2.0.0",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@types/react-router": "^5.1.18",
"@types/react-helmet": "^6.1.6",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"@types/react-stickynode": "^4.0.0",
"@types/react-transition-group": "^4.4.5",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"@vitejs/plugin-react-swc": "^3.0.0",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.10.0",
"eslint-config-airbnb": "19.0.4",
@@ -91,7 +99,7 @@
"vite-plugin-package-version": "^1.0.2",
"vite-plugin-pwa": "^0.14.4",
"vitest": "^0.28.5",
"workbox-window": "^6.5.4",
"@types/react-helmet": "^6.1.6"
"workbox-build": "^6.5.4",
"workbox-window": "^6.5.4"
}
}

View File

@@ -1,46 +1,28 @@
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import toWebVTT from "srt-webvtt";
import { MWCaption } from "@/backend/helpers/streams";
import DOMPurify from "dompurify";
import { parse, detect, list } from "subsrt-ts";
import { ContentCaption } from "subsrt-ts/dist/types/handler";
export const CUSTOM_CAPTION_ID = "customCaption";
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.type === MWCaptionType.SRT) {
let captionBlob: Blob;
if (caption.needsProxy) {
captionBlob = await proxiedFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
} else {
captionBlob = await mwFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
}
return toWebVTT(captionBlob);
}
if (caption.type === MWCaptionType.VTT) {
if (caption.needsProxy) {
const blob = await proxiedFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
return URL.createObjectURL(blob);
}
return caption.url;
}
throw new Error("invalid type");
export const customCaption = "external-custom";
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
}
export async function convertCustomCaptionFileToWebVTT(file: File) {
const header = await file.slice(0, 6).text();
const isWebVTT = header === "WEBVTT";
if (!isWebVTT) {
return toWebVTT(file);
export const subtitleTypeList = list().map((type) => `.${type}`);
export const sanitize = DOMPurify.sanitize;
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.url.startsWith("blob:")) return caption.url;
let captionBlob: Blob;
if (caption.needsProxy) {
captionBlob = await proxiedFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
} else {
captionBlob = await mwFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
}
return URL.createObjectURL(file);
return URL.createObjectURL(captionBlob);
}
export function revokeCaptionBlob(url: string | undefined) {
@@ -48,3 +30,12 @@ export function revokeCaptionBlob(url: string | undefined) {
URL.revokeObjectURL(url);
}
}
export function parseSubtitles(text: string): ContentCaption[] {
if (detect(text) === "") {
throw new Error("Invalid subtitle format");
}
return parse(text).filter(
(cue) => cue.type === "caption"
) as ContentCaption[];
}

View File

@@ -6,6 +6,7 @@ export enum MWStreamType {
export enum MWCaptionType {
VTT = "vtt",
SRT = "srt",
UNKNOWN = "unknown",
}
export enum MWStreamQuality {

View File

@@ -1,11 +1,12 @@
import { initializeScraperStore } from "./helpers/register";
// providers
import "./providers/gdriveplayer";
// import "./providers/gdriveplayer";
import "./providers/flixhq";
import "./providers/superstream";
import "./providers/netfilm";
import "./providers/m4ufree";
import "./providers/hdwatched";
// embeds
import "./embeds/streamm4u";

View File

@@ -21,7 +21,7 @@ export type JWMediaResult = {
title: string;
poster?: string;
id: number;
original_release_year: number;
original_release_year?: number;
jw_entity_id: string;
object_type: JWContentTypes;
seasons?: JWSeasonShort[];
@@ -67,7 +67,7 @@ export function formatJWMeta(
return {
title: media.title,
id: media.id.toString(),
year: media.original_release_year.toString(),
year: media.original_release_year?.toString(),
poster: media.poster
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
: undefined,

View File

@@ -24,7 +24,7 @@ export type MWSeasonWithEpisodeMeta = {
type MWMediaMetaBase = {
title: string;
id: string;
year: string;
year?: string;
poster?: string;
};

View File

@@ -8,25 +8,15 @@ import {
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
// const flixHqBase = "https://api.consumet.org/movies/flixhq";
// *** TEMPORARY FIX - use other instance
// SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326
const flixHqBase = "https://c.delusionz.xyz/movies/flixhq";
const flixHqBase = "https://api.consumet.org/meta/tmdb";
type FlixHQMediaType = "Movie" | "TV Series";
interface FLIXMediaBase {
id: number;
title: string;
url: string;
image: string;
}
interface FLIXTVSerie extends FLIXMediaBase {
type: "TV Series";
seasons: number | null;
}
interface FLIXMovie extends FLIXMediaBase {
type: "Movie";
type: FlixHQMediaType;
releaseDate: string;
}
@@ -49,13 +39,17 @@ const qualityMap: Record<string, MWStreamQuality> = {
"1080": MWStreamQuality.Q1080P,
};
function flixTypeToMWType(type: FlixHQMediaType) {
if (type === "Movie") return MWMediaType.MOVIE;
return MWMediaType.SERIES;
}
registerProvider({
id: "flixhq",
displayName: "FlixHQ",
rank: 100,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, progress }) {
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
@@ -66,42 +60,48 @@ registerProvider({
baseURL: flixHqBase,
}
);
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
if (media.meta.type === MWMediaType.MOVIE) {
const movie = v as FLIXMovie;
return (
compareTitle(movie.title, media.meta.title) &&
movie.releaseDate === media.meta.year
);
}
const serie = v as FLIXTVSerie;
if (serie.seasons && media.meta.seasons) {
return (
compareTitle(serie.title, media.meta.title) &&
serie.seasons === media.meta.seasons.length
);
}
return compareTitle(serie.title, media.meta.title);
if (v.type !== "Movie" && v.type !== "TV Series") return false;
return (
compareTitle(v.title, media.meta.title) &&
flixTypeToMWType(v.type) === media.meta.type &&
v.releaseDate === media.meta.year
);
});
if (!foundItem) throw new Error("No watchable item found");
const flixId = foundItem.id;
// get media info
progress(25);
const mediaInfo = await proxiedFetch<any>("/info", {
const mediaInfo = await proxiedFetch<any>(`/info/${foundItem.id}`, {
baseURL: flixHqBase,
params: {
id: flixId,
type: flixTypeToMWType(foundItem.type),
},
});
if (!mediaInfo.episodes) throw new Error("No watchable item found");
if (!mediaInfo.id) throw new Error("No watchable item found");
// get stream info from media
progress(50);
let episodeId: string | undefined;
if (media.meta.type === MWMediaType.MOVIE) {
episodeId = mediaInfo.episodeId;
} else if (media.meta.type === MWMediaType.SERIES) {
const seasonNo = media.meta.seasonData.number;
const episodeNo = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
}
if (!episodeId) throw new Error("No watchable item found");
progress(75);
const watchInfo = await proxiedFetch<any>("/watch", {
const watchInfo = await proxiedFetch<any>(`/watch/${episodeId}`, {
baseURL: flixHqBase,
params: {
episodeId: mediaInfo.episodes[0].id,
mediaId: flixId,
id: mediaInfo.id,
},
});

View File

@@ -0,0 +1,196 @@
import { proxiedFetch } from "../helpers/fetch";
import { MWProviderContext } from "../helpers/provider";
import { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const hdwatchedBase = "https://www.hdwatched.xyz";
const qualityMap: Record<number, MWStreamQuality> = {
360: MWStreamQuality.Q360P,
540: MWStreamQuality.Q540P,
480: MWStreamQuality.Q480P,
720: MWStreamQuality.Q720P,
1080: MWStreamQuality.Q1080P,
};
interface SearchRes {
title: string;
year?: number;
href: string;
id: string;
}
function getStreamFromEmbed(stream: string) {
const embedPage = new DOMParser().parseFromString(stream, "text/html");
const source = embedPage.querySelector("#vjsplayer > source");
if (!source) {
throw new Error("Unable to fetch stream");
}
const streamSrc = source.getAttribute("src");
const streamRes = source.getAttribute("res");
if (!streamSrc || !streamRes) throw new Error("Unable to find stream");
return {
streamUrl: streamSrc,
quality:
streamRes && typeof +streamRes === "number"
? qualityMap[+streamRes]
: MWStreamQuality.QUNKNOWN,
};
}
async function fetchMovie(targetSource: SearchRes) {
const stream = await proxiedFetch<any>(`/embed/${targetSource.id}`, {
baseURL: hdwatchedBase,
});
const embedPage = new DOMParser().parseFromString(stream, "text/html");
const source = embedPage.querySelector("#vjsplayer > source");
if (!source) {
throw new Error("Unable to fetch movie stream");
}
return getStreamFromEmbed(stream);
}
async function fetchSeries(
targetSource: SearchRes,
{ media, episode, progress }: MWProviderContext
) {
if (media.meta.type !== MWMediaType.SERIES)
throw new Error("Media type mismatch");
const seasonNumber = media.meta.seasonData.number;
const episodeNumber = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
if (!seasonNumber || !episodeNumber)
throw new Error("Unable to get season or episode number");
const seriesPage = await proxiedFetch<any>(
`${targetSource.href}?season=${media.meta.seasonData.number}`,
{
baseURL: hdwatchedBase,
}
);
const seasonPage = new DOMParser().parseFromString(seriesPage, "text/html");
const pageElements = seasonPage.querySelectorAll("div.i-container");
const seriesList: SearchRes[] = [];
pageElements.forEach((pageElement) => {
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
const title =
pageElement?.querySelector("span.content-title")?.textContent || "";
seriesList.push({
title,
href,
id: href.split("/")[2], // Format: /free/{id}/{series-slug}-season-{season-number}-episode-{episode-number}
});
});
const targetEpisode = seriesList.find(
(episodeEl) =>
episodeEl.title.trim().toLowerCase() === `episode ${episodeNumber}`
);
if (!targetEpisode) throw new Error("Unable to find episode");
progress(70);
const stream = await proxiedFetch<any>(`/embed/${targetEpisode.id}`, {
baseURL: hdwatchedBase,
});
const embedPage = new DOMParser().parseFromString(stream, "text/html");
const source = embedPage.querySelector("#vjsplayer > source");
if (!source) {
throw new Error("Unable to fetch movie stream");
}
return getStreamFromEmbed(stream);
}
registerProvider({
id: "hdwatched",
displayName: "HDwatched",
rank: 150,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape(options) {
const { media, progress } = options;
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
const search = await proxiedFetch<any>(`/search/${media.imdbId}`, {
baseURL: hdwatchedBase,
});
const searchPage = new DOMParser().parseFromString(search, "text/html");
const pageElements = searchPage.querySelectorAll("div.i-container");
const searchList: SearchRes[] = [];
pageElements.forEach((pageElement) => {
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
const title =
pageElement?.querySelector("span.content-title")?.textContent || "";
const year =
parseInt(
pageElement
?.querySelector("div.duration")
?.textContent?.trim()
?.split(" ")
?.pop() || "",
10
) || 0;
searchList.push({
title,
year,
href,
id: href.split("/")[2], // Format: /free/{id}/{movie-slug} or /series/{id}/{series-slug}
});
});
progress(20);
const targetSource = searchList.find(
(source) => source.year === (media.meta.year ? +media.meta.year : 0) // Compare year to make the search more robust
);
if (!targetSource) {
throw new Error("Could not find stream");
}
progress(40);
if (media.meta.type === MWMediaType.SERIES) {
const series = await fetchSeries(targetSource, options);
return {
embeds: [],
stream: {
streamUrl: series.streamUrl,
quality: series.quality,
type: MWStreamType.MP4,
captions: [],
},
};
}
const movie = await fetchMovie(targetSource);
return {
embeds: [],
stream: {
streamUrl: movie.streamUrl,
quality: movie.quality,
type: MWStreamType.MP4,
captions: [],
},
};
},
});

View File

@@ -22,6 +22,7 @@ registerProvider({
displayName: "NetFilm",
rank: 15,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
disabled: true, // The creator has asked us (very nicely) to leave him alone. Until (if) we self-host, netfilm should remain disabled
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {

View File

@@ -225,15 +225,21 @@ registerProvider({
const subtitleRes = (await get(subtitleApiQuery)).data;
const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => {
return {
needsProxy: true,
langIso: subtitle.language,
url: subtitle.subtitles[0].file_path,
type: MWCaptionType.SRT,
};
});
const mappedCaptions = subtitleRes.list.map(
(subtitle: any): MWCaption | null => {
const sub = subtitle;
sub.subtitles = subtitle.subtitles.filter((subFile: any) => {
const extension = subFile.file_path.slice(-3);
return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension);
});
return {
needsProxy: true,
langIso: subtitle.language,
url: sub.subtitles[0].file_path,
type: MWCaptionType.SRT,
};
}
);
return {
embeds: [],
stream: {

View File

@@ -0,0 +1,29 @@
import { useSettings } from "@/state/settings";
import { Icon, Icons } from "./Icon";
export const colors = ["#ffffff", "#00ffff", "#ffff00"];
export default function CaptionColorSelector({ color }: { color: string }) {
const { captionSettings, setCaptionColor } = useSettings();
return (
<div
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${
color === captionSettings.style.color ? "bg-[#1C161B]" : ""
}`}
onClick={() => setCaptionColor(color)}
>
<div
className="h-4 w-4 cursor-pointer appearance-none rounded-full"
style={{
backgroundColor: color,
}}
/>
<Icon
className={[
"absolute text-xs text-[#1C161B]",
color === captionSettings.style.color ? "" : "hidden",
].join(" ")}
icon={Icons.CHECKMARK}
/>
</div>
);
}

View File

@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:bottom-10 sm:text-sm">
<Listbox.Options className="absolute top-10 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
{props.options.map((opt) => (
<Listbox.Option
className={({ active }) =>

View File

@@ -39,6 +39,8 @@ export enum Icons {
GEAR = "gear",
WATCH_PARTY = "watch_party",
PICTURE_IN_PICTURE = "pictureInPicture",
CHECKMARK = "checkmark",
TACHOMETER = "tachometer",
}
export interface IconProps {
@@ -85,6 +87,8 @@ const iconList: Record<Icons, string> = {
gear: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>`,
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`,
pictureInPicture: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/></svg>`,
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
tachometer: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 576 512"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M128 288c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zm154.65-97.08l16.24-48.71c1.16-3.45 3.18-6.35 4.92-9.43-4.73-2.76-9.94-4.78-15.81-4.78-17.67 0-32 14.33-32 32 0 15.78 11.63 28.29 26.65 30.92zM176 176c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm212.27 400H75.73C57.56 397.63 48 359.12 48 320 48 187.66 155.66 80 288 80s240 107.66 240 240c0 39.12-9.56 77.63-27.73 112zM416 320c0 17.67 14.33 32 32 32s32-14.33 32-32-14.33-32-32-32-32 14.33-32 32zm-56.41-182.77c-12.72-4.23-26.16 2.62-30.38 15.17l-45.34 136.01C250.49 290.58 224 318.06 224 352c0 11.72 3.38 22.55 8.88 32h110.25c5.5-9.45 8.88-20.28 8.88-32 0-19.45-8.86-36.66-22.55-48.4l45.34-136.01c4.17-12.57-2.64-26.17-15.21-30.36zM432 208c0-15.8-11.66-28.33-26.72-30.93-.07.21-.07.43-.14.65l-19.5 58.49c4.37 2.24 9.11 3.8 14.36 3.8 17.67-.01 32-14.34 32-32.01z"/></svg>`,
};
function ChromeCastButton() {

47
src/components/Slider.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { ChangeEventHandler, useEffect, useRef } from "react";
export type SliderProps = {
label?: string;
min: number;
max: number;
step: number;
value?: number;
valueDisplay?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
};
export function Slider(props: SliderProps) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
const e = ref.current as HTMLInputElement;
e.style.setProperty("--value", e.value);
e.style.setProperty("--min", e.min === "" ? "0" : e.min);
e.style.setProperty("--max", e.max === "" ? "100" : e.max);
e.addEventListener("input", () => e.style.setProperty("--value", e.value));
}, [ref]);
return (
<div className="mb-6 flex flex-row gap-4">
<div className="flex w-full flex-col gap-2">
{props.label ? (
<label className="font-bold">{props.label}</label>
) : null}
<input
type="range"
ref={ref}
className="styled-slider slider-progress mt-[20px]"
onChange={props.onChange}
value={props.value}
max={props.max}
min={props.min}
step={props.step}
/>
</div>
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1">
<div className="text-center font-bold text-white">
{props.valueDisplay ?? props.value}
</div>
</div>
</div>
);
}

View File

@@ -35,9 +35,14 @@ export function Modal(props: Props) {
);
}
export function ModalCard(props: { children?: ReactNode }) {
export function ModalCard(props: { className?: string; children?: ReactNode }) {
return (
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10">
<div
className={[
"relative mx-2 w-[500px] overflow-hidden rounded-lg bg-denim-300 px-10 py-10 sm:w-[500px] md:w-[500px] lg:w-[1000px]",
props.className ?? "",
].join(" ")}
>
{props.children}
</div>
);

View File

@@ -1,9 +1,10 @@
import { ReactNode } from "react";
import { ReactNode, useState } from "react";
import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { conf } from "@/setup/config";
import { useBannerSize } from "@/hooks/useBanner";
import SettingsModal from "@/views/SettingsModal";
import { BrandPill } from "./BrandPill";
export interface NavigationProps {
@@ -13,7 +14,7 @@ export interface NavigationProps {
export function Navigation(props: NavigationProps) {
const bannerHeight = useBannerSize();
const [showModal, setShowModal] = useState(false);
return (
<div
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
@@ -42,6 +43,14 @@ export function Navigation(props: NavigationProps) {
props.children ? "hidden sm:flex" : "flex"
} relative flex-row gap-4`}
>
<IconPatch
className="text-2xl text-white"
icon={Icons.GEAR}
clickable
onClick={() => {
setShowModal(true);
}}
/>
<a
href={conf().DISCORD_LINK}
target="_blank"
@@ -60,6 +69,7 @@ export function Navigation(props: NavigationProps) {
</a>
</div>
</div>
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
</div>
);
}

View File

@@ -33,6 +33,9 @@ function MediaCardContent({
const canLink = linkable && !closable;
const dotListContent = [t(`media.${media.type}`)];
if (media.year) dotListContent.push(media.year);
return (
<div
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
@@ -115,10 +118,7 @@ function MediaCardContent({
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
<span>{media.title}</span>
</h1>
<DotList
className="text-xs"
content={[t(`media.${media.type}`), media.year]}
/>
<DotList className="text-xs" content={dotListContent} />
</article>
</div>
);

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
import { useIsMobile } from "@/hooks/useIsMobile";
@@ -133,13 +134,15 @@ export const FloatingCardView = {
action?: React.ReactNode;
backText?: string;
}) {
const { t } = useTranslation();
let left = (
<div
onClick={props.goBack}
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<Icon icon={Icons.ARROW_LEFT} />
<span>{props.backText || "Go back"}</span>
<span>{props.backText || t("videoPlayer.popouts.back")}</span>
</div>
);
if (props.close)

View File

@@ -1,5 +1,11 @@
import { Transition } from "@/components/Transition";
import React, { ReactNode, useCallback, useEffect, useRef } from "react";
import React, {
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
interface Props {
@@ -10,6 +16,8 @@ interface Props {
}
export function FloatingContainer(props: Props) {
const [portalElement, setPortalElement] = useState<Element | null>(null);
const ref = useRef<HTMLDivElement>(null);
const target = useRef<Element | null>(null);
useEffect(() => {
@@ -34,23 +42,34 @@ export function FloatingContainer(props: Props) {
[props]
);
return createPortal(
<Transition show={props.show} animation="none">
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none">
<Transition animation="fade" isChild>
<div
onClick={click}
className={[
"absolute inset-0",
props.darken ? "bg-black opacity-90" : "",
].join(" ")}
/>
</Transition>
<Transition animation="slide-up" className="h-0" isChild>
{props.children}
</Transition>
</div>
</Transition>,
document.body
useEffect(() => {
const element = ref.current?.closest(".popout-location");
setPortalElement(element ?? document.body);
}, []);
return (
<div ref={ref}>
{portalElement
? createPortal(
<Transition show={props.show} animation="none">
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none">
<Transition animation="fade" isChild>
<div
onClick={click}
className={[
"absolute inset-0",
props.darken ? "bg-black opacity-90" : "",
].join(" ")}
/>
</Transition>
<Transition animation="slide-up" className="h-0" isChild>
{props.children}
</Transition>
</div>
</Transition>,
portalElement
)
: null}
</div>
);
}

View File

@@ -29,6 +29,7 @@ export function FloatingView(props: Props) {
data-floating-page={props.show ? "true" : undefined}
style={{
height: props.height ? `${props.height}px` : undefined,
maxHeight: "70vh",
width: props.width ? width : undefined,
}}
>

View File

@@ -21,8 +21,20 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
}));
const bind = useDrag(
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => {
({
last,
velocity: [, vy],
direction: [, dy],
movement: [, my],
...event
}) => {
if (closing.current) return;
const isInScrollable = (event.target as HTMLDivElement).closest(
".overflow-y-auto"
);
if (isInScrollable) return; // Don't attempt to swipe the thing away if it's a scroll area unless the scroll area is at the top and the user is swiping down
const height = cardRect?.height ?? 0;
if (last) {
// if past half height downwards
@@ -69,7 +81,7 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
return (
<div
className="absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
className="is-mobile-view absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
style={{
transform: `translateY(${
window.innerHeight - (cardRect?.height ?? 0) + 200

View File

@@ -6,7 +6,7 @@ function getInitialValue(params: { type: string; query: string }) {
const type =
Object.values(MWMediaType).find((v) => params.type === v) ||
MWMediaType.MOVIE;
const searchQuery = params.query || "";
const searchQuery = decodeURIComponent(params.query || "");
return { type, searchQuery };
}

View File

@@ -1,12 +1,15 @@
import React, { ReactNode, Suspense } from "react";
import "core-js/stable";
import React, { Suspense } from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, HashRouter } from "react-router-dom";
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import { conf } from "@/setup/config";
import { registerSW } from "virtual:pwa-register";
import App from "@/setup/App";
import "@/setup/ga";
import "@/setup/sentry";
import "@/setup/i18n";
import "@/setup/index.css";
import "@/backend";
@@ -21,9 +24,7 @@ if (key) {
}
initializeChromecast();
registerSW({
onNeedRefresh() {
window.location.reload();
},
immediate: true,
});
const LazyLoadedApp = React.lazy(async () => {

View File

@@ -1,62 +1,91 @@
import { lazy } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { BookmarkContextProvider } from "@/state/bookmark";
import { WatchedContextProvider } from "@/state/watched";
import { SettingsProvider } from "@/state/settings";
import { NotFoundPage } from "@/views/notfound/NotFoundView";
import { MediaView } from "@/views/media/MediaView";
import { SearchView } from "@/views/search/SearchView";
import { MWMediaType } from "@/backend/metadata/types";
import { V2MigrationView } from "@/views/other/v2Migration";
import { 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";
import { TestView } from "@/views/developer/TestView";
function App() {
return (
<WatchedContextProvider>
<BookmarkContextProvider>
<BannerContextProvider>
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
<SettingsProvider>
<WatchedContextProvider>
<BookmarkContextProvider>
<BannerContextProvider>
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
{/* pages */}
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<Route
exact
path="/search/:type/:query?"
component={SearchView}
/>
{/* pages */}
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<Route
exact
path="/search/:type/:query?"
component={SearchView}
/>
{/* other */}
<Route exact path="/dev" component={DeveloperView} />
<Route exact path="/dev/test" component={TestView} />
<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>
</WatchedContextProvider>
{/* other */}
{process.env.NODE_ENV === "development" ? (
<>
<Route
exact
path="/dev"
component={lazy(
() => import("@/views/developer/DeveloperView")
)}
/>
<Route
exact
path="/dev/test"
component={lazy(
() => import("@/views/developer/TestView")
)}
/>
<Route
exact
path="/dev/video"
component={lazy(
() => import("@/views/developer/VideoTesterView")
)}
/>
<Route
exact
path="/dev/providers"
component={lazy(
() => import("@/views/developer/ProviderTesterView")
)}
/>
<Route
exact
path="/dev/embeds"
component={lazy(
() => import("@/views/developer/EmbedTesterView")
)}
/>
</>
) : null}
<Route path="*" component={NotFoundPage} />
</Switch>
</Layout>
</BannerContextProvider>
</BookmarkContextProvider>
</WatchedContextProvider>
</SettingsProvider>
);
}

View File

@@ -1,4 +1,6 @@
export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
export const APP_VERSION = "3.0.6";
export const GA_ID = "G-44YVXRL61C";
export const SENTRY_DSN =
"https://b267ab7d52674c23af4e4e6cf2956251@o4505053491167232.ingest.sentry.io/4505053495296000";

View File

@@ -4,7 +4,17 @@ import LanguageDetector from "i18next-browser-languagedetector";
// Languages
import en from "./locales/en/translation.json";
import nl from "./locales/nl/translation.json";
import { captionLanguages } from "./iso6391";
const locales = {
en: {
translation: en,
},
nl: {
translation: nl,
},
};
i18n
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
@@ -15,16 +25,14 @@ i18n
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
fallbackLng: "en",
resources: {
en: {
translation: en,
},
},
resources: locales,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
});
export const appLanguageOptions = captionLanguages.filter((x) => {
return Object.keys(locales).includes(x.id);
});
export default i18n;

View File

@@ -38,6 +38,7 @@ body[data-no-select] {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
@@ -54,3 +55,127 @@ body[data-no-select] {
.google-cast-button:not(.casting) google-cast-launcher {
@apply brightness-[500];
}
.is-mobile-view .overflow-y-auto {
height: 60vh;
}
/*generated with Input range slider CSS style generator (version 20211225)
https://toughengineer.github.io/demo/slider-styler*/
:root {
--slider-height: 0.25rem;
--slider-border-radius: 1em;
--slider-progress-background: #8652bb;
}
input[type=range].styled-slider {
height: var(--slider-height);
-webkit-appearance: none;
appearance: none;
border-radius: var(--slider-border-radius);
background: #1C161B;
}
/*progress support*/
input[type=range].styled-slider.slider-progress {
--range: calc(var(--max) - var(--min));
--ratio: calc((var(--value) - var(--min)) / var(--range));
--sx: calc(0.5 * 1rem + var(--ratio) * (100% - 1rem));
}
/*webkit*/
input[type=range].styled-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 1rem;
height: 1rem;
border-radius: var(--slider-border-radius);
background: #FFFFFF;
border: none;
box-shadow: 0 0 2px #000000;
margin-top: calc(0.25em * 0.5 - 1rem * 0.5);
}
input[type=range].styled-slider::-webkit-slider-runnable-track {
height: var(--slider-height);
border: none;
box-shadow: none;
border-radius: var(--slider-border-radius);
}
input[type=range].styled-slider::-webkit-slider-thumb:hover {
background: #DCDCDC;
}
input[type=range].styled-slider.slider-progress::-webkit-slider-runnable-track {
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
}
/*mozilla*/
input[type=range].styled-slider::-moz-range-thumb {
width: 1rem;
height: 1rem;
border-radius: var(--slider-border-radius);
background: #FFFFFF;
border: none;
box-shadow: 0 0 2px #000000;
}
input[type=range].styled-slider::-moz-range-track {
height: var(--slider-height);
border: none;
border-radius: var(--slider-border-radius);
background: #1C161B;
box-shadow: none;
}
input[type=range].styled-slider::-moz-range-thumb:hover {
background: #DCDCDC;
}
input[type=range].styled-slider.slider-progress::-moz-range-track {
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
}
/*ms*/
input[type=range].styled-slider::-ms-fill-upper {
background: transparent;
border-color: transparent;
}
input[type=range].styled-slider::-ms-fill-lower {
background: transparent;
border-color: transparent;
}
input[type=range].styled-slider::-ms-thumb {
width: 1rem;
height: 1rem;
border-radius: var(--slider-border-radius);
background: #FFFFFF;
border: none;
box-shadow: 0 0 2px #000000;
margin-top: 0;
box-sizing: border-box;
}
input[type=range].styled-slider::-ms-track {
height: var(--slider-height);
border-radius: var(--slider-border-radius);
background: #1C161B;
border: none;
box-shadow: none;
box-sizing: border-box;
}
input[type=range].styled-slider::-ms-thumb:hover {
background: #DCDCDC;
}
input[type=range].styled-slider.slider-progress::-ms-fill-lower {
height: var(--slider-height);
border-radius: var(--slider-border-radius) 0 0 5px;
margin: -undefined 0 -undefined -undefined;
background: var(--slider-progress-background);
border: none;
border-right-width: 0;
}

1326
src/setup/iso6391.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -57,24 +57,38 @@
"backToHome": "Back to home",
"backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} left",
"finishAt": "Finish at {{timeFinished}}",
"buttons": {
"episodes": "Episodes",
"source": "Source",
"captions": "Captions",
"download": "Download",
"settings": "Settings",
"pictureInPicture": "Picture in Picture"
"pictureInPicture": "Picture in Picture",
"playbackSpeed": "Playback speed"
},
"popouts": {
"back": "Go back",
"sources": "Sources",
"seasons": "Seasons",
"captions": "Captions",
"playbackSpeed": "Playback speed",
"customPlaybackSpeed": "Custom playback speed",
"captionPreferences": {
"title": "Customize",
"delay": "Delay",
"fontSize": "Size",
"opacity": "Opacity",
"color": "Color"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "No captions",
"linkedCaptions": "Linked captions",
"customCaption": "Custom caption",
"uploadCustomCaption": "Upload caption (SRT, VTT)",
"uploadCustomCaption": "Upload caption",
"noEmbeds": "No embeds were found for this source",
"errors": {
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
"embedsError": "Something went wrong loading the embeds for this thing that you like"
@@ -84,13 +98,20 @@
"embeds": "Choose which video to view",
"seasons": "Choose which season you want to watch",
"episode": "Pick an episode",
"captions": "Choose a subtitle language"
"captions": "Choose a subtitle language",
"captionPreferences": "Make subtitles look how you want it",
"playbackSpeed": "Change the playback speed"
}
},
"errors": {
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
}
},
"settings": {
"title": "Settings",
"language": "Language",
"captionLanguage": "Caption Language"
},
"v3": {
"newSiteTitle": "New version now released!",
"newDomain": "https://movie-web.app",

View File

@@ -0,0 +1,100 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Recherche de votre série préférée...",
"loading_movie": "Recherche de vos films préférés...",
"loading": "Chargement...",
"allResults": "C'est tout ce que nous avons!",
"noResults": "Nous n'avons rien trouvé!",
"allFailed": "Le média n'a pas été trouvé, veuillez réessayez!",
"headingTitle": "Résultats de la recherche",
"bookmarks": "Favoris",
"continueWatching": "Continuer le visionnage",
"title": "Que voulez-vous voir?",
"placeholder": "Que voulez-vous voir?"
},
"media": {
"title": "Impossible de trouver ce média",
"description": "Nous n'avons pas pu trouver le média que vous avez demandé. Soit il a été supprimé, soit vous avez altéré l'URL."
},
"provider": {
"title": "Ce fournisseur a été désactivé",
"description": "Nous avons eu des problèmes avec le fournisseur ou bien il était trop instable pour être utilisé, donc nous avons dû le désactiver."
},
"page": {
"title": "Impossible de trouver cette page",
"description": "Nous avons cherché partout : sous les poubelles, dans le placard, derrière le proxy, mais nous n'avons finalement pas pu trouver la page que vous recherchez."
},
"searchBar": {
"movie": "Film",
"series": "Série",
"Search": "Rechercher"
},
"videoPlayer": {
"findingBestVideo": "Recherche de la meilleure vidéo pour vous",
"noVideos": "Désolé, nous n'avons pas pu trouver de vidéos pour vous",
"loading": "Chargement...",
"backToHome": "Retour à la page d'accueil",
"backToHomeShort": "Retour",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} restant",
"finishAt": "Terminer à {{timeFinished}}",
"buttons": {
"episodes": "Épisodes",
"source": "Source",
"captions": "Sous-titres",
"download": "Télécharger",
"settings": "Paramètres",
"pictureInPicture": "Image dans l'image",
"playbackSpeed": "Vitesse"
},
"popouts": {
"sources": "Sources",
"seasons": "Saisons",
"captions": "Sous-titres",
"captionPreferences": {
"title": "Personnaliser",
"delay": "Délai",
"fontSize": "Taille",
"opacity": "Opacité",
"color": "Couleur"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "Pas de sous-titres",
"linkedCaptions": "Sous-titres liés",
"customCaption": "Sous-titres personnalisés",
"uploadCustomCaption": "Télécharger des sous-titres",
"noEmbeds": "Aucun contenu intégré n'a été trouvé pour cette source",
"errors": {
"loadingWentWong": "Un problème est survenu lors du chargement des épisodes pour {{seasonTitle}}",
"embedsError": "Un problème est survenu lors du chargement des contenus intégrés pour cet élément que vous aimez"
},
"descriptions": {
"sources": "Quel fournisseur voulez-vous utiliser ?",
"embeds": "Choisissez quelle vidéo regarder",
"seasons": "Choisissez la saison que vous voulez regarder",
"episode": "Sélectionnez un épisode",
"captions": "Choisissez une langue de sous-titres",
"captionPreferences": "Personnalisez l'apparence des sous-titres"
}
},
"errors": {
"fatalError": "Le lecteur vidéo a rencontré une erreur fatale, veuillez la signaler au serveur <0>Discord</0> ou sur <1>GitHub</1>."
}
},
"v3": {
"newSiteTitle": "Nouvelle version disponible!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web déménagera bientôt vers un nouveau domaine : <0>https://movie-web.app</0>. Veillez à mettre à jour tous vos favoris car <1>l'ancien site web cessera de fonctionner le {{date}}.</1>",
"tireless": "Nous avons travaillé sans relâche sur cette nouvelle mise à jour et nous espérons que vous apprécierez ce que nous avons préparé ces derniers mois.",
"leaveAnnouncement": "Emmenez-moi là!"
},
"casting": {
"casting": "Transmission à l'appareil..."
},
"errors": {
"offline": "Vérifiez votre connexion internet"
}
}

View File

@@ -0,0 +1,128 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "We zoeken je favoriete series...",
"loading_movie": "We zoeken je favoriete films...",
"loading": "Aan het zoeken...",
"allResults": "Dat is het!",
"noResults": "We konden helaas niets vinden.",
"allFailed": "Het is niet gelukt de media te laden, probeer het nog eens.",
"headingTitle": "Zoekresultaten",
"bookmarks": "Opgeslagen",
"continueWatching": "Kijk verder",
"title": "Wat wil je graag kijken?",
"placeholder": "Wat wil je graag kijken?"
},
"media": {
"movie": "Film",
"series": "Serie",
"stopEditing": "Stop met bewerken",
"errors": {
"genericTitle": "Oeps, hier ging iets mis!",
"failedMeta": "Het is niet gelukt de meta-informatie op te halen/",
"mediaFailed": "Het is niet gelukt deze media op te halen. Controleer of je een internetverbinding hebt en probeer het nog een keer.",
"videoFailed": "Er ging iets mis tijdens het spelen van deze video. Als dit blijft gebeuren, deel het dan in de <0>Discord server</0> of maak een <1>GitHub issue</1>."
}
},
"seasons": {
"seasonAndEpisode": "S{{season}} A{{episode}}"
},
"notFound": {
"genericTitle": "Pagina niet gevonden",
"backArrow": "Naar de home-pagina",
"media": {
"title": "We konden deze media niet vinden.",
"description": "We konden dit stukje media niet vinden. Het is mogelijk verwijderd, of jij hebt zelf de URL aangepast."
},
"provider": {
"title": "Deze bron is niet langer beschikbaar",
"description": "Deze bron was helaas te instabiel, we hebben hem jammer genoeg uit moeten zetten."
},
"page": {
"title": "Pagina niet gevonden",
"description": "We hebben echt alles geprobeerd, zelfs tijdrijzen; echter hebben we deze pagina helaas niet kunnen vinden."
}
},
"searchBar": {
"movie": "Films",
"series": "Series",
"Search": "Zoeken"
},
"videoPlayer": {
"findingBestVideo": "De beste video voor jou aan het zoeken...",
"noVideos": "Helaas konden we dat filmpje niet vinden",
"loading": "Aan het laden...",
"backToHome": "Naar de home-pagina",
"backToHomeShort": "Terug",
"seasonAndEpisode": "S{{season}} A{{episode}}",
"timeLeft": "Nog {{timeLeft}}",
"finishAt": "Afgelopen om {{timeFinished}}",
"buttons": {
"episodes": "Afleveringen",
"source": "Bron",
"captions": "Ondertiteling",
"download": "Download",
"settings": "Instellingen",
"pictureInPicture": "Beeld-in-beeld",
"playbackSpeed": "Afspeelsnelheid"
},
"popouts": {
"back": "Terug",
"sources": "Bronnen",
"seasons": "Seizoenen",
"captions": "Ondertiteling",
"playbackSpeed": "Afspeelsnelheid",
"customPlaybackSpeed": "Andere snelheden",
"captionPreferences": {
"title": "Instellingen",
"delay": "Vertraging",
"fontSize": "Lettergrootte",
"opacity": "Doorzichtbaarheid",
"color": "Kleur"
},
"episode": "A{{index}} - {{title}}",
"noCaptions": "Geen ondertiteling",
"linkedCaptions": "Gelinkte ondertiteling",
"customCaption": "Eigen ondertiteling",
"uploadCustomCaption": "Ondertiteling uploaden",
"noEmbeds": "We hebben geen filmpjes kunnen vinden voor deze bron.",
"errors": {
"loadingWentWong": "Er ging iets mis tijdens het laden van de afleveringen voor {{seasonTitle}}",
"embedsError": "Er ging iets mis tijdens het laden van de embeds voor dit dingetje dat je waarschijnlijk leuk vindt"
},
"descriptions": {
"sources": "Welke bron wil je graag gebruiken",
"embeds": "Welk filmpje wil je gebruiken?",
"seasons": "Welk seizoen wil je kijken?",
"episode": "Kies een aflevering",
"captions": "Kies een taal voor de ondertiteling",
"captionPreferences": "Pas de ondertiteling aan aan je voorkeuren",
"playbackSpeed": "Pas de afspeelsnelhijd aan"
}
},
"errors": {
"fatalError": "De videospeler is helaas ontploft, rapporteer deze fout op de <0>Discord server</0> of op <1>GitHub</1>."
}
},
"settings": {
"title": "Instellingen",
"language": "Taal",
"captionLanguage": "Taal voor de Ondertiteling"
},
"v3": {
"newSiteTitle": "De nieuwe versie is uit!",
"newDomain": "https://movie-web.app",
"newDomainText": "We gaan binnenkort verhuizen naar een nieuw domein: <0>https://movie-web.app</0>. Pas je bladwijzers aan naar het nieuwe domein, want </b>het oude domein gaat stoppen met werken op {{date}}.</b>",
"tireless": "We hebben mega hard gewerkt aan deze nieuwe versie, dus we hopen dat je er van gaat genieten.",
"leaveAnnouncement": "Let's go!"
},
"casting": {
"casting": "Aan het casten..."
},
"errors": {
"offline": "Controleer je internetverbinding"
}
}

15
src/setup/sentry.tsx Normal file
View File

@@ -0,0 +1,15 @@
import * as Sentry from "@sentry/react";
import { CaptureConsole, HttpClient } from "@sentry/integrations";
import { SENTRY_DSN } from "@/setup/constants";
import { conf } from "@/setup/config";
Sentry.init({
dsn: SENTRY_DSN,
release: `movie-web@${conf().APP_VERSION}`,
sampleRate: 0.5,
integrations: [
new Sentry.BrowserTracing(),
new CaptureConsole(),
new HttpClient(),
],
});

View File

@@ -0,0 +1,90 @@
import { useStore } from "@/utils/storage";
import { createContext, ReactNode, useContext, useMemo } from "react";
import { LangCode } from "@/setup/iso6391";
import { SettingsStore } from "./store";
import { MWSettingsData } from "./types";
interface MWSettingsDataSetters {
setLanguage(language: LangCode): void;
setCaptionLanguage(language: LangCode): void;
setCaptionDelay(delay: number): void;
setCaptionColor(color: string): void;
setCaptionFontSize(size: number): void;
setCaptionBackgroundColor(backgroundColor: number): void;
}
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
export function SettingsProvider(props: { children: ReactNode }) {
function enforceRange(min: number, value: number, max: number) {
return Math.max(min, Math.min(value, max));
}
const [settings, setSettings] = useStore(SettingsStore);
const context: MWSettingsDataWrapper = useMemo(() => {
const settingsContext: MWSettingsDataWrapper = {
...settings,
setLanguage(language) {
setSettings((oldSettings) => {
return {
...oldSettings,
language,
};
});
},
setCaptionLanguage(language) {
setSettings((oldSettings) => {
const captionSettings = oldSettings.captionSettings;
captionSettings.language = language;
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionDelay(delay: number) {
setSettings((oldSettings) => {
const captionSettings = oldSettings.captionSettings;
captionSettings.delay = enforceRange(-10, delay, 10);
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionColor(color) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.color = color;
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionFontSize(size) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.fontSize = enforceRange(10, size, 60);
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionBackgroundColor(backgroundColor) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.backgroundColor = `${style.backgroundColor.substring(
0,
7
)}${backgroundColor.toString(16).padStart(2, "0")}`;
const newSettings = oldSettings;
return newSettings;
});
},
};
return settingsContext;
}, [settings, setSettings]);
return (
<SettingsContext.Provider value={context}>
{props.children}
</SettingsContext.Provider>
);
}
export function useSettings() {
return useContext(SettingsContext);
}
export default SettingsContext;

View File

@@ -0,0 +1 @@
export * from "./context";

View File

@@ -0,0 +1,48 @@
import { createVersionedStore } from "@/utils/storage";
import { MWSettingsData, MWSettingsDataV1 } from "./types";
export const SettingsStore = createVersionedStore<MWSettingsData>()
.setKey("mw-settings")
.addVersion({
version: 0,
create(): MWSettingsDataV1 {
return {
language: "en",
captionSettings: {
delay: 0,
style: {
color: "#ffffff",
fontSize: 25,
backgroundColor: "#00000096",
},
},
};
},
migrate(data: MWSettingsDataV1): MWSettingsData {
return {
language: data.language,
captionSettings: {
language: "none",
...data.captionSettings,
},
};
},
})
.addVersion({
version: 1,
create(): MWSettingsData {
return {
language: "en",
captionSettings: {
delay: 0,
language: "none",
style: {
color: "#ffffff",
fontSize: 25,
backgroundColor: "#00000096",
},
},
};
},
})
.build();

View File

@@ -0,0 +1,36 @@
import { LangCode } from "@/setup/iso6391";
export interface CaptionStyleSettings {
color: string;
/**
* Range is [10, 30]
*/
fontSize: number;
backgroundColor: string;
}
export interface CaptionSettingsV1 {
/**
* Range is [-10, 10]s
*/
delay: number;
style: CaptionStyleSettings;
}
export interface CaptionSettings {
language: LangCode;
/**
* Range is [-10, 10]s
*/
delay: number;
style: CaptionStyleSettings;
}
export interface MWSettingsDataV1 {
language: LangCode;
captionSettings: CaptionSettingsV1;
}
export interface MWSettingsData {
language: LangCode;
captionSettings: CaptionSettings;
}

View File

@@ -99,7 +99,7 @@ function buildStorageObject<T>(store: InternalStoreData): StoreRet<T> {
localStorage.setItem(key, JSON.stringify(withVersion));
if (!storeCallbacks[key]) storeCallbacks[key] = [];
storeCallbacks[key].forEach((v) => v(structuredClone(data)));
storeCallbacks[key].forEach((v) => v(window.structuredClone(data)));
}
return {

View File

@@ -27,9 +27,11 @@ import { ReactNode, useCallback, useState } from "react";
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
import { SettingsAction } from "./actions/SettingsAction";
import { DividerAction } from "./actions/DividerAction";
import { PictureInPictureAction } from "./actions/PictureInPictureAction";
import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
type Props = VideoPlayerBaseProps;
@@ -90,6 +92,7 @@ export function VideoPlayer(props: Props) {
<>
<KeyboardShortcutsAction />
<PageTitleAction />
<VolumeAdjustedAction />
<VideoPlayerError onGoBack={props.onGoBack}>
<BackdropAction onBackdropChange={onBackdropChange}>
<CenterPosition>
@@ -165,6 +168,7 @@ export function VideoPlayer(props: Props) {
</Transition>
{show ? <PopoutProviderAction /> : null}
</BackdropAction>
<CaptionRendererAction isControlsShown={show} />
{props.children}
</VideoPlayerError>
</>

View File

@@ -39,7 +39,7 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
<div
ref={ref}
className={[
"is-video-player relative h-full w-full select-none overflow-hidden bg-black",
"is-video-player popout-location relative h-full w-full select-none overflow-hidden bg-black",
props.includeSafeArea || videoInterface.isFullscreen
? "[border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]"
: "",

View File

@@ -24,18 +24,16 @@ export function BackdropAction(props: BackdropActionProps) {
const handleMouseMove = useCallback(() => {
if (!moved) {
setTimeout(() => {
// If NOT a touch, set moved to true
const isTouch = Date.now() - lastTouchEnd.current < 200;
if (!isTouch) {
setMoved(true);
}
if (!isTouch) setMoved(true);
}, 20);
return;
}
// remove after all
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
if (moved) setMoved(false);
setMoved(false);
timeout.current = null;
}, 3000);
}, [setMoved, moved]);

View File

@@ -0,0 +1,95 @@
import { Transition } from "@/components/Transition";
import { useSettings } from "@/state/settings";
import { sanitize, parseSubtitles } from "@/backend/helpers/captions";
import { ContentCaption } from "subsrt-ts/dist/types/handler";
import { useRef } from "react";
import { useAsync } from "react-use";
import { useVideoPlayerDescriptor } from "../../state/hooks";
import { useProgress } from "../../state/logic/progress";
import { useSource } from "../../state/logic/source";
export function CaptionCue({ text, scale }: { text?: string; scale?: number }) {
const { captionSettings } = useSettings();
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
// added a <br /> for newlines
const html = sanitize(textWithNewlines, {
ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"],
ADD_TAGS: ["v", "lang"],
ALLOWED_ATTR: ["title", "lang"],
});
return (
<p
className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
style={{
...captionSettings.style,
fontSize: captionSettings.style.fontSize * (scale ?? 1),
}}
>
<span
// its sanitised a few lines up
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: html,
}}
dir="auto"
/>
</p>
);
}
export function CaptionRendererAction({
isControlsShown,
}: {
isControlsShown: boolean;
}) {
const descriptor = useVideoPlayerDescriptor();
const source = useSource(descriptor).source;
const videoTime = useProgress(descriptor).time;
const { captionSettings } = useSettings();
const captions = useRef<ContentCaption[]>([]);
useAsync(async () => {
const blobUrl = source?.caption?.url;
if (blobUrl) {
const result = await fetch(blobUrl);
const text = await result.text();
try {
captions.current = parseSubtitles(text);
} catch (error) {
captions.current = [];
}
} else {
captions.current = [];
}
}, [source?.caption?.url]);
if (!captions.current.length) return null;
const isVisible = (start: number, end: number): boolean => {
const delayedStart = start / 1000 + captionSettings.delay;
const delayedEnd = end / 1000 + captionSettings.delay;
return (
Math.max(0, delayedStart) <= videoTime &&
Math.max(0, delayedEnd) >= videoTime
);
};
return (
<Transition
className={[
"pointer-events-none absolute flex w-full flex-col items-center transition-[bottom]",
isControlsShown ? "bottom-24" : "bottom-12",
].join(" ")}
animation="slide-up"
show
>
{captions.current.map(
({ start, end, content }) =>
isVisible(start, end) && (
<CaptionCue key={`${start}-${end}`} text={content} />
)
)}
</Transition>
);
}

View File

@@ -63,6 +63,16 @@ export function KeyboardShortcutsAction() {
toggleVolume();
break;
// Decrease volume
case "arrowdown":
controls.setVolume(Math.max(mediaPlaying.volume - 0.1, 0), true);
break;
// Increase volume
case "arrowup":
controls.setVolume(Math.min(mediaPlaying.volume + 0.1, 1), true);
break;
// Do a barrel Roll!
case "r":
if (isRolling || evt.ctrlKey || evt.metaKey) return;

View File

@@ -1,6 +1,11 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useTranslation } from "react-i18next";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useProgress } from "@/video/state/logic/progress";
import { useInterface } from "@/video/state/logic/interface";
import { VideoPlayerTimeFormat } from "@/video/state/types";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useControls } from "@/video/state/logic/controls";
function durationExceedsHour(secs: number): boolean {
return secs > 60 * 60;
@@ -37,19 +42,71 @@ export function TimeAction(props: Props) {
const descriptor = useVideoPlayerDescriptor();
const videoTime = useProgress(descriptor);
const mediaPlaying = useMediaPlaying(descriptor);
const { setTimeFormat } = useControls(descriptor);
const { timeFormat } = useInterface(descriptor);
const { isMobile } = useIsMobile();
const { t } = useTranslation();
const hasHours = durationExceedsHour(videoTime.duration);
const time = formatSeconds(
const currentTime = formatSeconds(
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
hasHours
);
const duration = formatSeconds(videoTime.duration, hasHours);
const timeLeft = formatSeconds(
(videoTime.duration - videoTime.time) / mediaPlaying.playbackSpeed,
hasHours
);
const timeFinished = new Date(
new Date().getTime() +
(videoTime.duration * 1000) / mediaPlaying.playbackSpeed
).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
});
const formattedTimeFinished = ` - ${t("videoPlayer.finishAt", {
timeFinished,
})}`;
let formattedTime: string;
if (timeFormat === VideoPlayerTimeFormat.REGULAR) {
formattedTime = `${currentTime} ${props.noDuration ? "" : `/ ${duration}`}`;
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && !isMobile) {
formattedTime = `${t("videoPlayer.timeLeft", {
timeLeft,
})}${videoTime.time === videoTime.duration ? "" : formattedTimeFinished} `;
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && isMobile) {
formattedTime = `-${timeLeft}`;
} else {
formattedTime = "";
}
return (
<div className={props.className}>
<p className="select-none text-white">
{time} {props.noDuration ? "" : `/ ${duration}`}
</p>
</div>
<button
type="button"
className={[
"group pointer-events-auto text-white transition-transform duration-100 active:scale-110",
].join(" ")}
onClick={() => {
setTimeFormat(
timeFormat === VideoPlayerTimeFormat.REGULAR
? VideoPlayerTimeFormat.REMAINING
: VideoPlayerTimeFormat.REGULAR
);
}}
>
<div
className={[
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100 sm:px-4",
].join(" ")}
>
<div className={props.className}>
<p className="select-none text-white">{formattedTime}</p>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,32 @@
import { Icon, Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useInterface } from "@/video/state/logic/interface";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
export function VolumeAdjustedAction() {
const descriptor = useVideoPlayerDescriptor();
const videoInterface = useInterface(descriptor);
const mediaPlaying = useMediaPlaying(descriptor);
return (
<div
className={[
videoInterface.volumeChangedWithKeybind
? "mt-10 scale-100 opacity-100"
: "mt-5 scale-75 opacity-0",
"absolute left-1/2 z-[100] flex -translate-x-1/2 items-center space-x-4 rounded-full bg-bink-300 bg-opacity-50 py-2 px-5 transition-all duration-100",
].join(" ")}
>
<Icon
icon={mediaPlaying.volume > 0 ? Icons.VOLUME : Icons.VOLUME_X}
className="text-xl text-white"
/>
<div className="h-2 w-44 overflow-hidden rounded-full bg-denim-100">
<div
className="h-full rounded-r-full bg-bink-500 transition-[width] duration-100"
style={{ width: `${mediaPlaying.volume * 100}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Icons } from "@/components/Icon";
import { useTranslation } from "react-i18next";
import { PopoutListAction } from "../../popouts/PopoutUtils";
interface Props {
onClick: () => any;
}
export function PlaybackSpeedSelectionAction(props: Props) {
const { t } = useTranslation();
return (
<PopoutListAction icon={Icons.TACHOMETER} onClick={props.onClick}>
{t("videoPlayer.buttons.playbackSpeed")}
</PopoutListAction>
);
}

View File

@@ -1,4 +1,4 @@
import { Icon, Icons } from "@/components/Icon";
import { Icons } from "@/components/Icon";
import { useTranslation } from "react-i18next";
import { PopoutListAction } from "../../popouts/PopoutUtils";
import { QualityDisplayAction } from "./QualityDisplayAction";

View File

@@ -1,4 +1,11 @@
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { getCaptionUrl, makeCaptionId } from "@/backend/helpers/captions";
import {
MWCaption,
MWStreamQuality,
MWStreamType,
} from "@/backend/helpers/streams";
import { captionLanguages } from "@/setup/iso6391";
import { useSettings } from "@/state/settings";
import { useInitialized } from "@/video/components/hooks/useInitialized";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
@@ -10,6 +17,19 @@ interface SourceControllerProps {
quality: MWStreamQuality;
providerId?: string;
embedId?: string;
captions: MWCaption[];
}
async function tryFetch(captions: MWCaption[]) {
for (let i = 0; i < captions.length; i += 1) {
const caption = captions[i];
try {
const blobUrl = await getCaptionUrl(caption);
return { caption, blobUrl };
} catch (error) {
continue;
}
}
return null;
}
export function SourceController(props: SourceControllerProps) {
@@ -17,13 +37,35 @@ export function SourceController(props: SourceControllerProps) {
const controls = useControls(descriptor);
const { initialized } = useInitialized(descriptor);
const didInitialize = useRef<boolean>(false);
const { captionSettings } = useSettings();
useEffect(() => {
if (didInitialize.current) return;
if (!initialized) return;
controls.setSource(props);
// get preferred language
const preferredLanguage = captionLanguages.find(
(v) => v.id === captionSettings.language
);
if (!preferredLanguage) return;
const captions = props.captions.filter(
(v) =>
// langIso may contain the English name or the native name of the language
v.langIso.indexOf(preferredLanguage.englishName) !== -1 ||
v.langIso.indexOf(preferredLanguage.nativeName) !== -1
);
if (!captions) return;
// caption url can return a response other than 200
// that's why we fetch until we get a 200 response
tryFetch(captions).then((response) => {
// none of them were successful
if (!response) return;
// set the preferred language
const id = makeCaptionId(response.caption, true);
controls.setCaption(id, response.blobUrl);
});
didInitialize.current = true;
}, [props, controls, initialized]);
}, [props, controls, initialized, captionSettings.language]);
return null;
}

View File

@@ -1,7 +1,6 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useMisc } from "@/video/state/logic/misc";
import { useSource } from "@/video/state/logic/source";
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider";
import { useEffect, useMemo, useRef } from "react";
@@ -13,7 +12,6 @@ interface Props {
function VideoElement(props: Props) {
const descriptor = useVideoPlayerDescriptor();
const mediaPlaying = useMediaPlaying(descriptor);
const source = useSource(descriptor);
const misc = useMisc(descriptor);
const ref = useRef<HTMLVideoElement>(null);
@@ -43,12 +41,8 @@ function VideoElement(props: Props) {
autoPlay={props.autoPlay}
muted={mediaPlaying.volume === 0}
playsInline
className="h-full w-full"
>
{source.source?.caption ? (
<track default kind="captions" src={source.source.caption.url} />
) : null}
</video>
className="z-0 h-full w-full"
/>
);
}

View File

@@ -2,8 +2,9 @@ import { MWMediaMeta } from "@/backend/metadata/types";
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
import { Link } from "@/components/text/Link";
import { conf } from "@/setup/config";
import { Component, ReactNode } from "react";
import { Component } from "react";
import { Trans } from "react-i18next";
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
import { VideoPlayerHeader } from "./VideoPlayerHeader";
interface ErrorBoundaryState {

View File

@@ -1,9 +1,11 @@
import {
customCaption,
getCaptionUrl,
convertCustomCaptionFileToWebVTT,
CUSTOM_CAPTION_ID,
makeCaptionId,
parseSubtitles,
subtitleTypeList,
} from "@/backend/helpers/captions";
import { MWCaption } from "@/backend/helpers/streams";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import { Icon, Icons } from "@/components/Icon";
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingView } from "@/components/popout/FloatingView";
@@ -13,14 +15,10 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useMeta } from "@/video/state/logic/meta";
import { useSource } from "@/video/state/logic/source";
import { ChangeEvent, useMemo, useRef } from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
}
export function CaptionSelectionPopout(props: {
router: ReturnType<typeof useFloatingRouter>;
prefix: string;
@@ -41,36 +39,20 @@ export function CaptionSelectionPopout(props: {
async (caption: MWCaption, isLinked: boolean) => {
const id = makeCaptionId(caption, isLinked);
loadingId.current = id;
controls.setCaption(id, await getCaptionUrl(caption));
controls.closePopout();
const blobUrl = await getCaptionUrl(caption);
const result = await fetch(blobUrl);
const text = await result.text();
parseSubtitles(text); // This will throw if the file is invalid
controls.setCaption(id, blobUrl);
// sometimes this doesn't work, so we add a small delay
setTimeout(() => {
controls.closePopout();
}, 100);
}
);
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 (
<FloatingView
{...props.router.pageProps(props.prefix)}
@@ -81,6 +63,18 @@ export function CaptionSelectionPopout(props: {
title={t("videoPlayer.popouts.captions")}
description={t("videoPlayer.popouts.descriptions.captions")}
goBack={() => props.router.navigate("/")}
action={
<button
type="button"
onClick={() =>
props.router.navigate(`${props.prefix}/caption-settings`)
}
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<span>{t("videoPlayer.popouts.captionPreferences.title")}</span>
<Icon icon={Icons.GEAR} />
</button>
}
/>
<FloatingCardView.Content noSection>
<PopoutSection>
@@ -94,23 +88,29 @@ export function CaptionSelectionPopout(props: {
{t("videoPlayer.popouts.noCaptions")}
</PopoutListEntry>
<PopoutListEntry
key={CUSTOM_CAPTION_ID}
active={currentCaption === CUSTOM_CAPTION_ID}
loading={loadingCustomCaption}
errored={!!errorCustomCaption}
onClick={() => {
customCaptionUploadElement.current?.click();
}}
key={customCaption}
active={currentCaption === customCaption}
loading={loading && loadingId.current === customCaption}
errored={error && loadingId.current === customCaption}
onClick={() => customCaptionUploadElement.current?.click()}
>
{currentCaption === CUSTOM_CAPTION_ID
{currentCaption === customCaption
? t("videoPlayer.popouts.customCaption")
: t("videoPlayer.popouts.uploadCustomCaption")}
<input
ref={customCaptionUploadElement}
type="file"
onChange={handleUploadCaption}
className="hidden"
accept=".vtt, .srt"
ref={customCaptionUploadElement}
accept={subtitleTypeList.join(",")}
type="file"
onChange={(e) => {
if (!e.target.files) return;
const customSubtitle = {
langIso: "custom",
url: URL.createObjectURL(e.target.files[0]),
type: MWCaptionType.UNKNOWN,
};
setCaption(customSubtitle, false);
}}
/>
</PopoutListEntry>
</PopoutSection>

View File

@@ -0,0 +1,81 @@
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingView } from "@/components/popout/FloatingView";
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { useSettings } from "@/state/settings";
import { useTranslation } from "react-i18next";
import { Slider } from "@/components/Slider";
import CaptionColorSelector, {
colors,
} from "@/components/CaptionColorSelector";
export function CaptionSettingsPopout(props: {
router: ReturnType<typeof useFloatingRouter>;
prefix: string;
}) {
// For now, won't add label texts to language files since options are prone to change
const { t } = useTranslation();
const {
captionSettings,
setCaptionBackgroundColor,
setCaptionDelay,
setCaptionFontSize,
} = useSettings();
return (
<FloatingView {...props.router.pageProps(props.prefix)} width={375}>
<FloatingCardView.Header
title={t("videoPlayer.popouts.captionPreferences.title")}
description={t("videoPlayer.popouts.descriptions.captionPreferences")}
goBack={() => props.router.navigate("/captions")}
/>
<FloatingCardView.Content>
<Slider
label={t("videoPlayer.popouts.captionPreferences.delay") as string}
max={10}
min={-10}
step={0.1}
valueDisplay={`${captionSettings.delay.toFixed(1)}s`}
value={captionSettings.delay}
onChange={(e) => setCaptionDelay(e.target.valueAsNumber)}
/>
<Slider
label={t("videoPlayer.popouts.captionPreferences.fontSize") as string}
min={14}
step={1}
max={60}
value={captionSettings.style.fontSize}
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
/>
<Slider
label={t("videoPlayer.popouts.captionPreferences.opacity") as string}
step={1}
min={0}
max={255}
valueDisplay={`${(
(parseInt(
captionSettings.style.backgroundColor.substring(7, 9),
16
) /
255) *
100
).toFixed(0)}%`}
value={parseInt(
captionSettings.style.backgroundColor.substring(7, 9),
16
)}
onChange={(e) => setCaptionBackgroundColor(e.target.valueAsNumber)}
/>
<div className="flex flex-row justify-between">
<label className="font-bold" htmlFor="color">
{t("videoPlayer.popouts.captionPreferences.color")}
</label>
<div className="flex flex-row gap-2">
{colors.map((color) => (
<CaptionColorSelector color={color} />
))}
</div>
</div>
</FloatingCardView.Content>
</FloatingView>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { useLoading } from "@/hooks/useLoading";

View File

@@ -0,0 +1,73 @@
import { Icon, Icons } from "@/components/Icon";
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingView } from "@/components/popout/FloatingView";
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useTranslation } from "react-i18next";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { Slider } from "@/components/Slider";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
const speedSelectionOptions = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
export function PlaybackSpeedPopout(props: {
router: ReturnType<typeof useFloatingRouter>;
prefix: string;
}) {
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const mediaPlaying = useMediaPlaying(descriptor);
return (
<FloatingView
{...props.router.pageProps(props.prefix)}
width={320}
height={500}
>
<FloatingCardView.Header
title={t("videoPlayer.popouts.playbackSpeed")}
description={t("videoPlayer.popouts.descriptions.playbackSpeed")}
goBack={() => props.router.navigate("/")}
/>
<FloatingCardView.Content noSection>
<PopoutSection>
{speedSelectionOptions.map((speed) => (
<PopoutListEntry
key={speed}
active={mediaPlaying.playbackSpeed === speed}
onClick={() => {
controls.setPlaybackSpeed(speed);
controls.closePopout();
}}
>
{speed}x
</PopoutListEntry>
))}
</PopoutSection>
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-300 px-5 py-3 text-xs font-bold uppercase">
<Icon className="text-base" icon={Icons.TACHOMETER} />
<span>{t("videoPlayer.popouts.customPlaybackSpeed")}</span>
</p>
<PopoutSection className="pt-0">
<div>
<Slider
min={0.1}
max={10}
step={0.1}
value={mediaPlaying.playbackSpeed}
valueDisplay={`${mediaPlaying.playbackSpeed}x`}
onChange={(e: { target: { valueAsNumber: number } }) =>
controls.setPlaybackSpeed(e.target.valueAsNumber)
}
/>
</div>
</PopoutSection>
</FloatingCardView.Content>
</FloatingView>
);
}

View File

@@ -43,6 +43,8 @@ export function ScrollToActive(props: ScrollToActiveProps) {
const ref = createRef<HTMLDivElement>();
const inited = useRef<boolean>(false);
const SAFE_OFFSET = 30;
// Scroll to "active" child on first load (AKA mount except React dumb)
useEffect(() => {
if (inited.current) return;
@@ -61,27 +63,31 @@ export function ScrollToActive(props: ScrollToActiveProps) {
wrapper?.querySelector(".active");
if (wrapper && active) {
let activeYPositionCentered = 0;
const setActiveYPositionCentered = () => {
activeYPositionCentered =
active.getBoundingClientRect().top -
wrapper.getBoundingClientRect().top +
active.offsetHeight / 2;
let wrapperHeight = 0;
let activePos = 0;
let activeHeight = 0;
let wrapperScroll = 0;
const getCoords = () => {
const activeRect = active.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
wrapperHeight = wrapperRect.height;
activeHeight = activeRect.height;
activePos = activeRect.top - wrapperRect.top + wrapper.scrollTop;
wrapperScroll = wrapper.scrollTop;
};
setActiveYPositionCentered();
getCoords();
if (activeYPositionCentered >= wrapper.offsetHeight / 2) {
// Check if the active element is below the vertical center line, then scroll it into center
const isVisible =
activePos + activeHeight <
wrapperScroll + wrapperHeight - SAFE_OFFSET ||
activePos > wrapperScroll + SAFE_OFFSET;
if (isVisible) {
const activeMiddlePos = activePos + activeHeight / 2; // pos of middle of active element
const viewMiddle = wrapperHeight / 2; // half of the available height
const pos = activeMiddlePos - viewMiddle;
wrapper.scrollTo({
top: activeYPositionCentered - wrapper.offsetHeight / 2,
});
}
setActiveYPositionCentered();
if (activeYPositionCentered > wrapper.offsetHeight / 2) {
// If the element is over the vertical center line, scroll to the end
wrapper.scrollTo({
top: wrapper.scrollHeight,
top: pos,
});
}
}

View File

@@ -5,8 +5,11 @@ import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { DownloadAction } from "@/video/components/actions/list-entries/DownloadAction";
import { CaptionsSelectionAction } from "@/video/components/actions/list-entries/CaptionsSelectionAction";
import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction";
import { PlaybackSpeedSelectionAction } from "@/video/components/actions/list-entries/PlaybackSpeedSelectionAction";
import { CaptionSelectionPopout } from "./CaptionSelectionPopout";
import { SourceSelectionPopout } from "./SourceSelectionPopout";
import { CaptionSettingsPopout } from "./CaptionSettingsPopout";
import { PlaybackSpeedPopout } from "./PlaybackSpeedPopout";
export function SettingsPopout() {
const floatingRouter = useFloatingRouter();
@@ -20,10 +23,18 @@ export function SettingsPopout() {
<DownloadAction />
<SourceSelectionAction onClick={() => navigate("/source")} />
<CaptionsSelectionAction onClick={() => navigate("/captions")} />
<PlaybackSpeedSelectionAction
onClick={() => navigate("/playback-speed")}
/>
</FloatingCardView.Content>
</FloatingView>
<SourceSelectionPopout router={floatingRouter} prefix="source" />
<CaptionSelectionPopout router={floatingRouter} prefix="captions" />
<CaptionSettingsPopout
router={floatingRouter}
prefix="caption-settings"
/>
<PlaybackSpeedPopout router={floatingRouter} prefix="playback-speed" />
</>
);
}

View File

@@ -13,6 +13,7 @@ export function resetForSource(s: VideoPlayerState) {
isFirstLoading: true,
hasPlayedOnce: false,
volume: state.mediaPlaying.volume, // volume settings needs to persist through resets
playbackSpeed: 1,
};
state.progress = {
time: 0,
@@ -31,6 +32,9 @@ function initPlayer(): VideoPlayerState {
isFocused: false,
leftControlHovering: false,
popoutBounds: null,
volumeChangedWithKeybind: false,
volumeChangedWithKeybindDebounce: null,
timeFormat: 0,
},
mediaPlaying: {
@@ -42,6 +46,7 @@ function initPlayer(): VideoPlayerState {
isFirstLoading: true,
hasPlayedOnce: false,
volume: 0,
playbackSpeed: 1,
},
progress: {

View File

@@ -1,7 +1,7 @@
import { updateInterface } from "@/video/state/logic/interface";
import { updateMeta } from "@/video/state/logic/meta";
import { updateProgress } from "@/video/state/logic/progress";
import { VideoPlayerMeta } from "@/video/state/types";
import { VideoPlayerMeta, VideoPlayerTimeFormat } from "@/video/state/types";
import { getPlayerState } from "../cache";
import { VideoPlayerStateController } from "../providers/providerTypes";
@@ -14,6 +14,8 @@ export type ControlMethods = {
setCurrentEpisode(sId: string, eId: string): void;
setDraggingTime(num: number): void;
togglePictureInPicture(): void;
setPlaybackSpeed(num: number): void;
setTimeFormat(num: VideoPlayerTimeFormat): void;
};
export function useControls(
@@ -47,8 +49,20 @@ export function useControls(
enterFullscreen() {
state.stateProvider?.enterFullscreen();
},
setVolume(volume) {
state.stateProvider?.setVolume(volume);
setVolume(volume, isKeyboardEvent = false) {
if (isKeyboardEvent) {
if (state.interface.volumeChangedWithKeybindDebounce)
clearTimeout(state.interface.volumeChangedWithKeybindDebounce);
state.interface.volumeChangedWithKeybind = true;
updateInterface(descriptor, state);
state.interface.volumeChangedWithKeybindDebounce = setTimeout(() => {
state.interface.volumeChangedWithKeybind = false;
updateInterface(descriptor, state);
}, 3e3);
}
state.stateProvider?.setVolume(volume, isKeyboardEvent);
},
startAirplay() {
state.stateProvider?.startAirplay();
@@ -105,5 +119,13 @@ export function useControls(
state.stateProvider?.togglePictureInPicture();
updateInterface(descriptor, state);
},
setPlaybackSpeed(num) {
state.stateProvider?.setPlaybackSpeed(num);
updateInterface(descriptor, state);
},
setTimeFormat(format) {
state.interface.timeFormat = format;
updateInterface(descriptor, state);
},
};
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { getPlayerState } from "../cache";
import { listenEvent, sendEvent, unlistenEvent } from "../events";
import { VideoPlayerState } from "../types";
import { VideoPlayerState, VideoPlayerTimeFormat } from "../types";
export type VideoInterfaceEvent = {
popout: string | null;
@@ -9,6 +9,8 @@ export type VideoInterfaceEvent = {
isFocused: boolean;
isFullscreen: boolean;
popoutBounds: null | DOMRect;
volumeChangedWithKeybind: boolean;
timeFormat: VideoPlayerTimeFormat;
};
function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
@@ -18,6 +20,8 @@ function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
isFocused: state.interface.isFocused,
isFullscreen: state.interface.isFullscreen,
popoutBounds: state.interface.popoutBounds,
volumeChangedWithKeybind: state.interface.volumeChangedWithKeybind,
timeFormat: state.interface.timeFormat,
};
}

View File

@@ -12,6 +12,7 @@ export type VideoMediaPlayingEvent = {
hasPlayedOnce: boolean;
isFirstLoading: boolean;
volume: number;
playbackSpeed: number;
};
function getMediaPlayingFromState(
@@ -26,6 +27,7 @@ function getMediaPlayingFromState(
isDragSeeking: state.mediaPlaying.isDragSeeking,
isFirstLoading: state.mediaPlaying.isFirstLoading,
volume: state.mediaPlaying.volume,
playbackSpeed: state.mediaPlaying.playbackSpeed,
};
}

View File

@@ -87,6 +87,23 @@ export function createCastingStateProvider(
togglePictureInPicture() {
// no picture in picture while casting
},
setPlaybackSpeed(num) {
const mediaInfo = new chrome.cast.media.MediaInfo(
state.meta?.meta.meta.id ?? "video",
"video/mp4"
);
(mediaInfo as any).contentUrl = state.source?.url;
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
mediaInfo.metadata = new chrome.cast.media.MovieMediaMetadata();
mediaInfo.metadata.title = state.meta?.meta.meta.title ?? "";
mediaInfo.customData = {
playbackRate: num,
};
const request = new chrome.cast.media.LoadRequest(mediaInfo);
request.autoplay = true;
const session = ins?.getCurrentSession();
session?.loadMedia(request);
},
async setVolume(v) {
// clamp time between 0 and 1
let volume = Math.min(v, 1);
@@ -114,7 +131,7 @@ export function createCastingStateProvider(
movieMeta.title = state.meta?.meta.meta.title ?? "";
const mediaInfo = new chrome.cast.media.MediaInfo(
state.meta?.meta.meta.id ?? "hello",
state.meta?.meta.meta.id ?? "video",
"video/mp4"
);
(mediaInfo as any).contentUrl = source?.source;

View File

@@ -16,12 +16,13 @@ export type VideoPlayerStateController = {
setSeeking(active: boolean): void;
exitFullscreen(): void;
enterFullscreen(): void;
setVolume(volume: number): void;
setVolume(volume: number, isKeyboardEvent?: boolean): void;
startAirplay(): void;
setCaption(id: string, url: string): void;
clearCaption(): void;
getId(): string;
togglePictureInPicture(): void;
setPlaybackSpeed(num: number): void;
};
export type VideoPlayerStateProvider = VideoPlayerStateController & {

View File

@@ -228,6 +228,11 @@ export function createVideoStateProvider(
}
}
},
setPlaybackSpeed(num) {
player.playbackRate = num;
state.mediaPlaying.playbackSpeed = num;
updateMediaPlaying(descriptor, state);
},
providerStart() {
this.setVolume(getStoredVolume());
@@ -276,8 +281,14 @@ export function createVideoStateProvider(
state.mediaPlaying.isLoading = false;
updateMediaPlaying(descriptor, state);
};
const ratechange = () => {
state.mediaPlaying.playbackSpeed = player.playbackRate;
updateMediaPlaying(descriptor, state);
};
const fullscreenchange = () => {
state.interface.isFullscreen = !!document.fullscreenElement;
state.interface.isFullscreen =
!!document.fullscreenElement || // other browsers
!!(document as any).webkitFullscreenElement; // safari
updateInterface(descriptor, state);
};
const volumechange = async () => {
@@ -324,6 +335,7 @@ export function createVideoStateProvider(
player.addEventListener("timeupdate", timeupdate);
player.addEventListener("loadedmetadata", loadedmetadata);
player.addEventListener("canplay", canplay);
player.addEventListener("ratechange", ratechange);
fscreen.addEventListener("fullscreenchange", fullscreenchange);
player.addEventListener("error", error);
player.addEventListener(

View File

@@ -22,14 +22,22 @@ export type VideoPlayerMeta = {
}[];
};
export enum VideoPlayerTimeFormat {
REGULAR = 0,
REMAINING = 1,
}
export type VideoPlayerState = {
// state related to the user interface
interface: {
isFullscreen: boolean;
popout: string | null; // id of current popout (eg source select, episode select)
isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused)
volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently?
volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig"
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
popoutBounds: null | DOMRect; // bounding box of current popout
timeFormat: VideoPlayerTimeFormat; // Time format of the video player
};
// state related to the playing state of the media
@@ -42,6 +50,7 @@ export type VideoPlayerState = {
isFirstLoading: boolean; // first buffering of the video, when set to false the video can start playing
hasPlayedOnce: boolean; // has the video played at all?
volume: number;
playbackSpeed: number;
};
// state related to video progress

147
src/views/SettingsModal.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { Dropdown } from "@/components/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { Modal, ModalCard } from "@/components/layout/Modal";
import { useSettings } from "@/state/settings";
import { useTranslation } from "react-i18next";
import { CaptionCue } from "@/video/components/actions/CaptionRendererAction";
import {
CaptionLanguageOption,
LangCode,
captionLanguages,
} from "@/setup/iso6391";
import { useMemo } from "react";
import { appLanguageOptions } from "@/setup/i18n";
import CaptionColorSelector, {
colors,
} from "@/components/CaptionColorSelector";
import { Slider } from "@/components/Slider";
import { conf } from "@/setup/config";
export default function SettingsModal(props: {
onClose: () => void;
show: boolean;
}) {
const {
captionSettings,
language,
setLanguage,
setCaptionLanguage,
setCaptionBackgroundColor,
setCaptionFontSize,
} = useSettings();
const { t, i18n } = useTranslation();
const selectedCaptionLanguage = useMemo(
() => captionLanguages.find((l) => l.id === captionSettings.language),
[captionSettings.language]
) as CaptionLanguageOption;
const appLanguage = useMemo(
() => appLanguageOptions.find((l) => l.id === language),
[language]
) as CaptionLanguageOption;
const captionBackgroundOpacity = (
(parseInt(captionSettings.style.backgroundColor.substring(7, 9), 16) /
255) *
100
).toFixed(0);
return (
<Modal show={props.show}>
<ModalCard className="text-white">
<div className="flex flex-col gap-4">
<div className="flex flex-row justify-between">
<span className="text-xl font-bold">{t("settings.title")}</span>
<div
onClick={() => props.onClose()}
className="hover:cursor-pointer"
>
<Icon icon={Icons.X} />
</div>
</div>
<div className="flex flex-col gap-10 lg:flex-row">
<div className="lg:w-1/2">
<div className="flex flex-col justify-between">
<label className="text-md font-semibold">
{t("settings.language")}
</label>
<Dropdown
selectedItem={appLanguage}
setSelectedItem={(val) => {
i18n.changeLanguage(val.id);
setLanguage(val.id as LangCode);
}}
options={appLanguageOptions}
/>
</div>
<div className="flex flex-col justify-between">
<label className="text-md font-semibold">
{t("settings.captionLanguage")}
</label>
<Dropdown
selectedItem={selectedCaptionLanguage}
setSelectedItem={(val) => {
setCaptionLanguage(val.id as LangCode);
}}
options={captionLanguages}
/>
</div>
<div className="flex flex-col justify-between">
<Slider
label={
t(
"videoPlayer.popouts.captionPreferences.fontSize"
) as string
}
min={14}
step={1}
max={60}
value={captionSettings.style.fontSize}
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
/>
<Slider
label={
t(
"videoPlayer.popouts.captionPreferences.opacity"
) as string
}
step={1}
min={0}
max={255}
valueDisplay={`${captionBackgroundOpacity}%`}
value={parseInt(
captionSettings.style.backgroundColor.substring(7, 9),
16
)}
onChange={(e) =>
setCaptionBackgroundColor(e.target.valueAsNumber)
}
/>
<div className="flex flex-row justify-between">
<label className="font-bold" htmlFor="color">
{t("videoPlayer.popouts.captionPreferences.color")}
</label>
<div className="flex flex-row gap-2">
{colors.map((color) => (
<CaptionColorSelector color={color} />
))}
</div>
</div>
</div>
<div />
</div>
<div className="flex w-full flex-col justify-center">
<div className="flex aspect-video flex-col justify-end rounded bg-zinc-800">
<div className="pointer-events-none flex w-full flex-col items-center transition-[bottom]">
<CaptionCue
scale={0.5}
text={selectedCaptionLanguage.nativeName}
/>
</div>
</div>
</div>
</div>
</div>
<div className="float-right mt-1 text-sm">v{conf().APP_VERSION}</div>
</ModalCard>
</Modal>
);
}

View File

@@ -3,7 +3,7 @@ import { ThinContainer } from "@/components/layout/ThinContainer";
import { ArrowLink } from "@/components/text/ArrowLink";
import { Title } from "@/components/text/Title";
export function DeveloperView() {
export default function DeveloperView() {
return (
<div className="py-48">
<Navigation />

View File

@@ -105,7 +105,7 @@ function EmbedScraperSelector(props: EmbedScraperSelectorProps) {
);
}
export function EmbedTesterView() {
export default function EmbedTesterView() {
const [embed, setEmbed] = useState<MWEmbed | null>(null);
const [embedScraperId, setEmbedScraperId] = useState<string | null>(null);
const embedScraper = useMemo(

View File

@@ -96,7 +96,7 @@ function ProviderSelector(props: ProviderSelectorProps) {
);
}
export function ProviderTesterView() {
export default function ProviderTesterView() {
const [media, setMedia] = useState<DetailedMeta | null>(null);
const [providerId, setProviderId] = useState<string | null>(null);

View File

@@ -1,4 +1,4 @@
// simple empty view, perfect for putting in tests
export function TestView() {
export default function TestView() {
return <div />;
}

View File

@@ -33,7 +33,7 @@ const testMeta: DetailedMeta = {
},
};
export function VideoTesterView() {
export default function VideoTesterView() {
const [video, setVideo] = useState<VideoData | null>(null);
const [videoType, setVideoType] = useState<MWStreamType>(MWStreamType.MP4);
const [url, setUrl] = useState("");
@@ -64,8 +64,9 @@ export function VideoTesterView() {
/>
<SourceController
source={video.streamUrl}
type={MWStreamType.MP4}
quality={MWStreamQuality.Q720P}
type={videoType}
quality={MWStreamQuality.QUNKNOWN}
captions={[]}
/>
</VideoPlayer>
</div>

View File

@@ -148,6 +148,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
quality={props.stream.quality}
embedId={props.stream.embedId}
providerId={props.stream.providerId}
captions={props.stream.captions}
/>
<ProgressListenerController
startAt={firstStartTime.current}

View File

@@ -1,5 +1,5 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import react from "@vitejs/plugin-react";
import loadVersion from "vite-plugin-package-version";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
@@ -7,10 +7,25 @@ import path from "path";
export default defineConfig({
plugins: [
react(),
react({
babel: {
presets: [
"@babel/preset-typescript",
[
"@babel/preset-env",
{
modules: false,
useBuiltIns: "entry",
corejs: {
version: "3.29",
},
},
],
],
},
}),
VitePWA({
registerType: "autoUpdate",
injectRegister: "inline",
workbox: {
globIgnores: ["**ping.txt**"],
},

2870
yarn.lock

File diff suppressed because it is too large Load Diff