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 - name: Install Yarn packages
run: yarn install run: yarn install
- name: Run ESLint Report - name: Run ESLint
run: yarn lint:report run: yarn lint
# 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
building: building:
name: Build project name: Build project

View File

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

View File

@@ -1,46 +1,28 @@
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; import { MWCaption } from "@/backend/helpers/streams";
import toWebVTT from "srt-webvtt"; 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 const customCaption = "external-custom";
export async function getCaptionUrl(caption: MWCaption): Promise<string> { export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
if (caption.type === MWCaptionType.SRT) { return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
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 subtitleTypeList = list().map((type) => `.${type}`);
export async function convertCustomCaptionFileToWebVTT(file: File) { export const sanitize = DOMPurify.sanitize;
const header = await file.slice(0, 6).text(); export async function getCaptionUrl(caption: MWCaption): Promise<string> {
const isWebVTT = header === "WEBVTT"; if (caption.url.startsWith("blob:")) return caption.url;
if (!isWebVTT) { let captionBlob: Blob;
return toWebVTT(file); 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) { export function revokeCaptionBlob(url: string | undefined) {
@@ -48,3 +30,12 @@ export function revokeCaptionBlob(url: string | undefined) {
URL.revokeObjectURL(url); 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 { export enum MWCaptionType {
VTT = "vtt", VTT = "vtt",
SRT = "srt", SRT = "srt",
UNKNOWN = "unknown",
} }
export enum MWStreamQuality { export enum MWStreamQuality {

View File

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

View File

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

View File

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

View File

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

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", displayName: "NetFilm",
rank: 15, rank: 15,
type: [MWMediaType.MOVIE, MWMediaType.SERIES], 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 }) { async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) { if (!this.type.includes(media.meta.type)) {

View File

@@ -225,15 +225,21 @@ registerProvider({
const subtitleRes = (await get(subtitleApiQuery)).data; const subtitleRes = (await get(subtitleApiQuery)).data;
const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => { const mappedCaptions = subtitleRes.list.map(
return { (subtitle: any): MWCaption | null => {
needsProxy: true, const sub = subtitle;
langIso: subtitle.language, sub.subtitles = subtitle.subtitles.filter((subFile: any) => {
url: subtitle.subtitles[0].file_path, const extension = subFile.file_path.slice(-3);
type: MWCaptionType.SRT, return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension);
}; });
}); return {
needsProxy: true,
langIso: subtitle.language,
url: sub.subtitles[0].file_path,
type: MWCaptionType.SRT,
};
}
);
return { return {
embeds: [], embeds: [],
stream: { 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" leaveFrom="opacity-100"
leaveTo="opacity-0" 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) => ( {props.options.map((opt) => (
<Listbox.Option <Listbox.Option
className={({ active }) => className={({ active }) =>

View File

@@ -39,6 +39,8 @@ export enum Icons {
GEAR = "gear", GEAR = "gear",
WATCH_PARTY = "watch_party", WATCH_PARTY = "watch_party",
PICTURE_IN_PICTURE = "pictureInPicture", PICTURE_IN_PICTURE = "pictureInPicture",
CHECKMARK = "checkmark",
TACHOMETER = "tachometer",
} }
export interface IconProps { 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>`, 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>`, 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>`, 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() { 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 ( 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} {props.children}
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,8 +21,20 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
})); }));
const bind = useDrag( const bind = useDrag(
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => { ({
last,
velocity: [, vy],
direction: [, dy],
movement: [, my],
...event
}) => {
if (closing.current) return; 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; const height = cardRect?.height ?? 0;
if (last) { if (last) {
// if past half height downwards // if past half height downwards
@@ -69,7 +81,7 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
return ( return (
<div <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={{ style={{
transform: `translateY(${ transform: `translateY(${
window.innerHeight - (cardRect?.height ?? 0) + 200 window.innerHeight - (cardRect?.height ?? 0) + 200

View File

@@ -6,7 +6,7 @@ function getInitialValue(params: { type: string; query: string }) {
const type = const type =
Object.values(MWMediaType).find((v) => params.type === v) || Object.values(MWMediaType).find((v) => params.type === v) ||
MWMediaType.MOVIE; MWMediaType.MOVIE;
const searchQuery = params.query || ""; const searchQuery = decodeURIComponent(params.query || "");
return { type, searchQuery }; 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 ReactDOM from "react-dom";
import { BrowserRouter, HashRouter } from "react-router-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 { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import { conf } from "@/setup/config"; import { conf } from "@/setup/config";
import { registerSW } from "virtual:pwa-register"; import { registerSW } from "virtual:pwa-register";
import App from "@/setup/App"; import App from "@/setup/App";
import "@/setup/ga"; import "@/setup/ga";
import "@/setup/sentry";
import "@/setup/i18n"; import "@/setup/i18n";
import "@/setup/index.css"; import "@/setup/index.css";
import "@/backend"; import "@/backend";
@@ -21,9 +24,7 @@ if (key) {
} }
initializeChromecast(); initializeChromecast();
registerSW({ registerSW({
onNeedRefresh() { immediate: true,
window.location.reload();
},
}); });
const LazyLoadedApp = React.lazy(async () => { const LazyLoadedApp = React.lazy(async () => {

View File

@@ -1,62 +1,91 @@
import { lazy } from "react";
import { Redirect, Route, Switch } from "react-router-dom"; import { Redirect, Route, Switch } from "react-router-dom";
import { BookmarkContextProvider } from "@/state/bookmark"; import { BookmarkContextProvider } from "@/state/bookmark";
import { WatchedContextProvider } from "@/state/watched"; import { WatchedContextProvider } from "@/state/watched";
import { SettingsProvider } from "@/state/settings";
import { NotFoundPage } from "@/views/notfound/NotFoundView"; import { NotFoundPage } from "@/views/notfound/NotFoundView";
import { MediaView } from "@/views/media/MediaView"; import { MediaView } from "@/views/media/MediaView";
import { SearchView } from "@/views/search/SearchView"; import { SearchView } from "@/views/search/SearchView";
import { MWMediaType } from "@/backend/metadata/types"; import { MWMediaType } from "@/backend/metadata/types";
import { V2MigrationView } from "@/views/other/v2Migration"; import { V2MigrationView } from "@/views/other/v2Migration";
import { DeveloperView } from "@/views/developer/DeveloperView";
import { VideoTesterView } from "@/views/developer/VideoTesterView";
import { ProviderTesterView } from "@/views/developer/ProviderTesterView";
import { EmbedTesterView } from "@/views/developer/EmbedTesterView";
import { BannerContextProvider } from "@/hooks/useBanner"; import { BannerContextProvider } from "@/hooks/useBanner";
import { Layout } from "@/setup/Layout"; import { Layout } from "@/setup/Layout";
import { TestView } from "@/views/developer/TestView";
function App() { function App() {
return ( return (
<WatchedContextProvider> <SettingsProvider>
<BookmarkContextProvider> <WatchedContextProvider>
<BannerContextProvider> <BookmarkContextProvider>
<Layout> <BannerContextProvider>
<Switch> <Layout>
{/* functional routes */} <Switch>
<Route exact path="/v2-migration" component={V2MigrationView} /> {/* functional routes */}
<Route exact path="/"> <Route exact path="/v2-migration" component={V2MigrationView} />
<Redirect to={`/search/${MWMediaType.MOVIE}`} /> <Route exact path="/">
</Route> <Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
{/* pages */} {/* pages */}
<Route exact path="/media/:media" component={MediaView} /> <Route exact path="/media/:media" component={MediaView} />
<Route <Route
exact exact
path="/media/:media/:season/:episode" path="/media/:media/:season/:episode"
component={MediaView} component={MediaView}
/> />
<Route <Route
exact exact
path="/search/:type/:query?" path="/search/:type/:query?"
component={SearchView} component={SearchView}
/> />
{/* other */} {/* other */}
<Route exact path="/dev" component={DeveloperView} /> {process.env.NODE_ENV === "development" ? (
<Route exact path="/dev/test" component={TestView} /> <>
<Route exact path="/dev/video" component={VideoTesterView} /> <Route
<Route exact
exact path="/dev"
path="/dev/providers" component={lazy(
component={ProviderTesterView} () => import("@/views/developer/DeveloperView")
/> )}
<Route exact path="/dev/embeds" component={EmbedTesterView} /> />
<Route path="*" component={NotFoundPage} /> <Route
</Switch> exact
</Layout> path="/dev/test"
</BannerContextProvider> component={lazy(
</BookmarkContextProvider> () => import("@/views/developer/TestView")
</WatchedContextProvider> )}
/>
<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 DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
export const GITHUB_LINK = "https://github.com/movie-web/movie-web"; export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
export const APP_VERSION = "3.0.6";
export const GA_ID = "G-44YVXRL61C"; 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 // Languages
import en from "./locales/en/translation.json"; 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 i18n
// detect user language // detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector // 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 // for all options read: https://www.i18next.com/overview/configuration-options
.init({ .init({
fallbackLng: "en", fallbackLng: "en",
resources: locales,
resources: {
en: {
translation: en,
},
},
interpolation: { interpolation: {
escapeValue: false, // not needed for react as it escapes by default 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; export default i18n;

View File

@@ -38,6 +38,7 @@ body[data-no-select] {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
@@ -54,3 +55,127 @@ body[data-no-select] {
.google-cast-button:not(.casting) google-cast-launcher { .google-cast-button:not(.casting) google-cast-launcher {
@apply brightness-[500]; @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", "backToHome": "Back to home",
"backToHomeShort": "Back", "backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}", "seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} left",
"finishAt": "Finish at {{timeFinished}}",
"buttons": { "buttons": {
"episodes": "Episodes", "episodes": "Episodes",
"source": "Source", "source": "Source",
"captions": "Captions", "captions": "Captions",
"download": "Download", "download": "Download",
"settings": "Settings", "settings": "Settings",
"pictureInPicture": "Picture in Picture" "pictureInPicture": "Picture in Picture",
"playbackSpeed": "Playback speed"
}, },
"popouts": { "popouts": {
"back": "Go back",
"sources": "Sources", "sources": "Sources",
"seasons": "Seasons", "seasons": "Seasons",
"captions": "Captions", "captions": "Captions",
"playbackSpeed": "Playback speed",
"customPlaybackSpeed": "Custom playback speed",
"captionPreferences": {
"title": "Customize",
"delay": "Delay",
"fontSize": "Size",
"opacity": "Opacity",
"color": "Color"
},
"episode": "E{{index}} - {{title}}", "episode": "E{{index}} - {{title}}",
"noCaptions": "No captions", "noCaptions": "No captions",
"linkedCaptions": "Linked captions", "linkedCaptions": "Linked captions",
"customCaption": "Custom caption", "customCaption": "Custom caption",
"uploadCustomCaption": "Upload caption (SRT, VTT)", "uploadCustomCaption": "Upload caption",
"noEmbeds": "No embeds were found for this source", "noEmbeds": "No embeds were found for this source",
"errors": { "errors": {
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}", "loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
"embedsError": "Something went wrong loading the embeds for this thing that you like" "embedsError": "Something went wrong loading the embeds for this thing that you like"
@@ -84,13 +98,20 @@
"embeds": "Choose which video to view", "embeds": "Choose which video to view",
"seasons": "Choose which season you want to watch", "seasons": "Choose which season you want to watch",
"episode": "Pick an episode", "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": { "errors": {
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>." "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": { "v3": {
"newSiteTitle": "New version now released!", "newSiteTitle": "New version now released!",
"newDomain": "https://movie-web.app", "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)); localStorage.setItem(key, JSON.stringify(withVersion));
if (!storeCallbacks[key]) storeCallbacks[key] = []; if (!storeCallbacks[key]) storeCallbacks[key] = [];
storeCallbacks[key].forEach((v) => v(structuredClone(data))); storeCallbacks[key].forEach((v) => v(window.structuredClone(data)));
} }
return { return {

View File

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

View File

@@ -39,7 +39,7 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
<div <div
ref={ref} ref={ref}
className={[ 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 props.includeSafeArea || videoInterface.isFullscreen
? "[border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]" ? "[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(() => { const handleMouseMove = useCallback(() => {
if (!moved) { if (!moved) {
setTimeout(() => { setTimeout(() => {
// If NOT a touch, set moved to true
const isTouch = Date.now() - lastTouchEnd.current < 200; const isTouch = Date.now() - lastTouchEnd.current < 200;
if (!isTouch) { if (!isTouch) setMoved(true);
setMoved(true);
}
}, 20); }, 20);
return;
} }
// remove after all // remove after all
if (timeout.current) clearTimeout(timeout.current); if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => { timeout.current = setTimeout(() => {
if (moved) setMoved(false); setMoved(false);
timeout.current = null; timeout.current = null;
}, 3000); }, 3000);
}, [setMoved, moved]); }, [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(); toggleVolume();
break; 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! // Do a barrel Roll!
case "r": case "r":
if (isRolling || evt.ctrlKey || evt.metaKey) return; if (isRolling || evt.ctrlKey || evt.metaKey) return;

View File

@@ -1,6 +1,11 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useTranslation } from "react-i18next";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useProgress } from "@/video/state/logic/progress"; 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 { function durationExceedsHour(secs: number): boolean {
return secs > 60 * 60; return secs > 60 * 60;
@@ -37,19 +42,71 @@ export function TimeAction(props: Props) {
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const videoTime = useProgress(descriptor); const videoTime = useProgress(descriptor);
const mediaPlaying = useMediaPlaying(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 hasHours = durationExceedsHour(videoTime.duration);
const time = formatSeconds(
const currentTime = formatSeconds(
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time, mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
hasHours hasHours
); );
const duration = formatSeconds(videoTime.duration, 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 ( return (
<div className={props.className}> <button
<p className="select-none text-white"> type="button"
{time} {props.noDuration ? "" : `/ ${duration}`} className={[
</p> "group pointer-events-auto text-white transition-transform duration-100 active:scale-110",
</div> ].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 { useTranslation } from "react-i18next";
import { PopoutListAction } from "../../popouts/PopoutUtils"; import { PopoutListAction } from "../../popouts/PopoutUtils";
import { QualityDisplayAction } from "./QualityDisplayAction"; 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 { useInitialized } from "@/video/components/hooks/useInitialized";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
@@ -10,6 +17,19 @@ interface SourceControllerProps {
quality: MWStreamQuality; quality: MWStreamQuality;
providerId?: string; providerId?: string;
embedId?: 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) { export function SourceController(props: SourceControllerProps) {
@@ -17,13 +37,35 @@ export function SourceController(props: SourceControllerProps) {
const controls = useControls(descriptor); const controls = useControls(descriptor);
const { initialized } = useInitialized(descriptor); const { initialized } = useInitialized(descriptor);
const didInitialize = useRef<boolean>(false); const didInitialize = useRef<boolean>(false);
const { captionSettings } = useSettings();
useEffect(() => { useEffect(() => {
if (didInitialize.current) return; if (didInitialize.current) return;
if (!initialized) return; if (!initialized) return;
controls.setSource(props); 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; didInitialize.current = true;
}, [props, controls, initialized]); }, [props, controls, initialized, captionSettings.language]);
return null; return null;
} }

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import { import {
customCaption,
getCaptionUrl, getCaptionUrl,
convertCustomCaptionFileToWebVTT, makeCaptionId,
CUSTOM_CAPTION_ID, parseSubtitles,
subtitleTypeList,
} from "@/backend/helpers/captions"; } from "@/backend/helpers/captions";
import { MWCaption } from "@/backend/helpers/streams"; import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { FloatingCardView } from "@/components/popout/FloatingCard"; import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingView } from "@/components/popout/FloatingView"; import { FloatingView } from "@/components/popout/FloatingView";
@@ -13,14 +15,10 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useMeta } from "@/video/state/logic/meta"; import { useMeta } from "@/video/state/logic/meta";
import { useSource } from "@/video/state/logic/source"; import { useSource } from "@/video/state/logic/source";
import { ChangeEvent, useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
}
export function CaptionSelectionPopout(props: { export function CaptionSelectionPopout(props: {
router: ReturnType<typeof useFloatingRouter>; router: ReturnType<typeof useFloatingRouter>;
prefix: string; prefix: string;
@@ -41,36 +39,20 @@ export function CaptionSelectionPopout(props: {
async (caption: MWCaption, isLinked: boolean) => { async (caption: MWCaption, isLinked: boolean) => {
const id = makeCaptionId(caption, isLinked); const id = makeCaptionId(caption, isLinked);
loadingId.current = id; loadingId.current = id;
controls.setCaption(id, await getCaptionUrl(caption)); const blobUrl = await getCaptionUrl(caption);
controls.closePopout(); 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 currentCaption = source.source?.caption?.id;
const customCaptionUploadElement = useRef<HTMLInputElement>(null); const customCaptionUploadElement = useRef<HTMLInputElement>(null);
const [setCustomCaption, loadingCustomCaption, errorCustomCaption] =
useLoading(async (captionFile: File) => {
if (
!captionFile.name.endsWith(".srt") &&
!captionFile.name.endsWith(".vtt")
) {
throw new Error("Only SRT or VTT files are allowed");
}
controls.setCaption(
CUSTOM_CAPTION_ID,
await convertCustomCaptionFileToWebVTT(captionFile)
);
controls.closePopout();
});
async function handleUploadCaption(e: ChangeEvent<HTMLInputElement>) {
if (!e.target.files) {
return;
}
const captionFile = e.target.files[0];
setCustomCaption(captionFile);
}
return ( return (
<FloatingView <FloatingView
{...props.router.pageProps(props.prefix)} {...props.router.pageProps(props.prefix)}
@@ -81,6 +63,18 @@ export function CaptionSelectionPopout(props: {
title={t("videoPlayer.popouts.captions")} title={t("videoPlayer.popouts.captions")}
description={t("videoPlayer.popouts.descriptions.captions")} description={t("videoPlayer.popouts.descriptions.captions")}
goBack={() => props.router.navigate("/")} 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> <FloatingCardView.Content noSection>
<PopoutSection> <PopoutSection>
@@ -94,23 +88,29 @@ export function CaptionSelectionPopout(props: {
{t("videoPlayer.popouts.noCaptions")} {t("videoPlayer.popouts.noCaptions")}
</PopoutListEntry> </PopoutListEntry>
<PopoutListEntry <PopoutListEntry
key={CUSTOM_CAPTION_ID} key={customCaption}
active={currentCaption === CUSTOM_CAPTION_ID} active={currentCaption === customCaption}
loading={loadingCustomCaption} loading={loading && loadingId.current === customCaption}
errored={!!errorCustomCaption} errored={error && loadingId.current === customCaption}
onClick={() => { onClick={() => customCaptionUploadElement.current?.click()}
customCaptionUploadElement.current?.click();
}}
> >
{currentCaption === CUSTOM_CAPTION_ID {currentCaption === customCaption
? t("videoPlayer.popouts.customCaption") ? t("videoPlayer.popouts.customCaption")
: t("videoPlayer.popouts.uploadCustomCaption")} : t("videoPlayer.popouts.uploadCustomCaption")}
<input <input
ref={customCaptionUploadElement}
type="file"
onChange={handleUploadCaption}
className="hidden" 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> </PopoutListEntry>
</PopoutSection> </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 { useParams } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { useLoading } from "@/hooks/useLoading"; 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 ref = createRef<HTMLDivElement>();
const inited = useRef<boolean>(false); const inited = useRef<boolean>(false);
const SAFE_OFFSET = 30;
// Scroll to "active" child on first load (AKA mount except React dumb) // Scroll to "active" child on first load (AKA mount except React dumb)
useEffect(() => { useEffect(() => {
if (inited.current) return; if (inited.current) return;
@@ -61,27 +63,31 @@ export function ScrollToActive(props: ScrollToActiveProps) {
wrapper?.querySelector(".active"); wrapper?.querySelector(".active");
if (wrapper && active) { if (wrapper && active) {
let activeYPositionCentered = 0; let wrapperHeight = 0;
const setActiveYPositionCentered = () => { let activePos = 0;
activeYPositionCentered = let activeHeight = 0;
active.getBoundingClientRect().top - let wrapperScroll = 0;
wrapper.getBoundingClientRect().top +
active.offsetHeight / 2; 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) { const isVisible =
// Check if the active element is below the vertical center line, then scroll it into center 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({ wrapper.scrollTo({
top: activeYPositionCentered - wrapper.offsetHeight / 2, top: pos,
});
}
setActiveYPositionCentered();
if (activeYPositionCentered > wrapper.offsetHeight / 2) {
// If the element is over the vertical center line, scroll to the end
wrapper.scrollTo({
top: wrapper.scrollHeight,
}); });
} }
} }

View File

@@ -5,8 +5,11 @@ import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { DownloadAction } from "@/video/components/actions/list-entries/DownloadAction"; import { DownloadAction } from "@/video/components/actions/list-entries/DownloadAction";
import { CaptionsSelectionAction } from "@/video/components/actions/list-entries/CaptionsSelectionAction"; import { CaptionsSelectionAction } from "@/video/components/actions/list-entries/CaptionsSelectionAction";
import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction"; import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction";
import { PlaybackSpeedSelectionAction } from "@/video/components/actions/list-entries/PlaybackSpeedSelectionAction";
import { CaptionSelectionPopout } from "./CaptionSelectionPopout"; import { CaptionSelectionPopout } from "./CaptionSelectionPopout";
import { SourceSelectionPopout } from "./SourceSelectionPopout"; import { SourceSelectionPopout } from "./SourceSelectionPopout";
import { CaptionSettingsPopout } from "./CaptionSettingsPopout";
import { PlaybackSpeedPopout } from "./PlaybackSpeedPopout";
export function SettingsPopout() { export function SettingsPopout() {
const floatingRouter = useFloatingRouter(); const floatingRouter = useFloatingRouter();
@@ -20,10 +23,18 @@ export function SettingsPopout() {
<DownloadAction /> <DownloadAction />
<SourceSelectionAction onClick={() => navigate("/source")} /> <SourceSelectionAction onClick={() => navigate("/source")} />
<CaptionsSelectionAction onClick={() => navigate("/captions")} /> <CaptionsSelectionAction onClick={() => navigate("/captions")} />
<PlaybackSpeedSelectionAction
onClick={() => navigate("/playback-speed")}
/>
</FloatingCardView.Content> </FloatingCardView.Content>
</FloatingView> </FloatingView>
<SourceSelectionPopout router={floatingRouter} prefix="source" /> <SourceSelectionPopout router={floatingRouter} prefix="source" />
<CaptionSelectionPopout router={floatingRouter} prefix="captions" /> <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, isFirstLoading: true,
hasPlayedOnce: false, hasPlayedOnce: false,
volume: state.mediaPlaying.volume, // volume settings needs to persist through resets volume: state.mediaPlaying.volume, // volume settings needs to persist through resets
playbackSpeed: 1,
}; };
state.progress = { state.progress = {
time: 0, time: 0,
@@ -31,6 +32,9 @@ function initPlayer(): VideoPlayerState {
isFocused: false, isFocused: false,
leftControlHovering: false, leftControlHovering: false,
popoutBounds: null, popoutBounds: null,
volumeChangedWithKeybind: false,
volumeChangedWithKeybindDebounce: null,
timeFormat: 0,
}, },
mediaPlaying: { mediaPlaying: {
@@ -42,6 +46,7 @@ function initPlayer(): VideoPlayerState {
isFirstLoading: true, isFirstLoading: true,
hasPlayedOnce: false, hasPlayedOnce: false,
volume: 0, volume: 0,
playbackSpeed: 1,
}, },
progress: { progress: {

View File

@@ -1,7 +1,7 @@
import { updateInterface } from "@/video/state/logic/interface"; import { updateInterface } from "@/video/state/logic/interface";
import { updateMeta } from "@/video/state/logic/meta"; import { updateMeta } from "@/video/state/logic/meta";
import { updateProgress } from "@/video/state/logic/progress"; 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 { getPlayerState } from "../cache";
import { VideoPlayerStateController } from "../providers/providerTypes"; import { VideoPlayerStateController } from "../providers/providerTypes";
@@ -14,6 +14,8 @@ export type ControlMethods = {
setCurrentEpisode(sId: string, eId: string): void; setCurrentEpisode(sId: string, eId: string): void;
setDraggingTime(num: number): void; setDraggingTime(num: number): void;
togglePictureInPicture(): void; togglePictureInPicture(): void;
setPlaybackSpeed(num: number): void;
setTimeFormat(num: VideoPlayerTimeFormat): void;
}; };
export function useControls( export function useControls(
@@ -47,8 +49,20 @@ export function useControls(
enterFullscreen() { enterFullscreen() {
state.stateProvider?.enterFullscreen(); state.stateProvider?.enterFullscreen();
}, },
setVolume(volume) { setVolume(volume, isKeyboardEvent = false) {
state.stateProvider?.setVolume(volume); 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() { startAirplay() {
state.stateProvider?.startAirplay(); state.stateProvider?.startAirplay();
@@ -105,5 +119,13 @@ export function useControls(
state.stateProvider?.togglePictureInPicture(); state.stateProvider?.togglePictureInPicture();
updateInterface(descriptor, state); 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 { useEffect, useState } from "react";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { listenEvent, sendEvent, unlistenEvent } from "../events"; import { listenEvent, sendEvent, unlistenEvent } from "../events";
import { VideoPlayerState } from "../types"; import { VideoPlayerState, VideoPlayerTimeFormat } from "../types";
export type VideoInterfaceEvent = { export type VideoInterfaceEvent = {
popout: string | null; popout: string | null;
@@ -9,6 +9,8 @@ export type VideoInterfaceEvent = {
isFocused: boolean; isFocused: boolean;
isFullscreen: boolean; isFullscreen: boolean;
popoutBounds: null | DOMRect; popoutBounds: null | DOMRect;
volumeChangedWithKeybind: boolean;
timeFormat: VideoPlayerTimeFormat;
}; };
function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent { function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
@@ -18,6 +20,8 @@ function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
isFocused: state.interface.isFocused, isFocused: state.interface.isFocused,
isFullscreen: state.interface.isFullscreen, isFullscreen: state.interface.isFullscreen,
popoutBounds: state.interface.popoutBounds, popoutBounds: state.interface.popoutBounds,
volumeChangedWithKeybind: state.interface.volumeChangedWithKeybind,
timeFormat: state.interface.timeFormat,
}; };
} }

View File

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

View File

@@ -87,6 +87,23 @@ export function createCastingStateProvider(
togglePictureInPicture() { togglePictureInPicture() {
// no picture in picture while casting // 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) { async setVolume(v) {
// clamp time between 0 and 1 // clamp time between 0 and 1
let volume = Math.min(v, 1); let volume = Math.min(v, 1);
@@ -114,7 +131,7 @@ export function createCastingStateProvider(
movieMeta.title = state.meta?.meta.meta.title ?? ""; movieMeta.title = state.meta?.meta.meta.title ?? "";
const mediaInfo = new chrome.cast.media.MediaInfo( const mediaInfo = new chrome.cast.media.MediaInfo(
state.meta?.meta.meta.id ?? "hello", state.meta?.meta.meta.id ?? "video",
"video/mp4" "video/mp4"
); );
(mediaInfo as any).contentUrl = source?.source; (mediaInfo as any).contentUrl = source?.source;

View File

@@ -16,12 +16,13 @@ export type VideoPlayerStateController = {
setSeeking(active: boolean): void; setSeeking(active: boolean): void;
exitFullscreen(): void; exitFullscreen(): void;
enterFullscreen(): void; enterFullscreen(): void;
setVolume(volume: number): void; setVolume(volume: number, isKeyboardEvent?: boolean): void;
startAirplay(): void; startAirplay(): void;
setCaption(id: string, url: string): void; setCaption(id: string, url: string): void;
clearCaption(): void; clearCaption(): void;
getId(): string; getId(): string;
togglePictureInPicture(): void; togglePictureInPicture(): void;
setPlaybackSpeed(num: number): void;
}; };
export type VideoPlayerStateProvider = VideoPlayerStateController & { 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() { providerStart() {
this.setVolume(getStoredVolume()); this.setVolume(getStoredVolume());
@@ -276,8 +281,14 @@ export function createVideoStateProvider(
state.mediaPlaying.isLoading = false; state.mediaPlaying.isLoading = false;
updateMediaPlaying(descriptor, state); updateMediaPlaying(descriptor, state);
}; };
const ratechange = () => {
state.mediaPlaying.playbackSpeed = player.playbackRate;
updateMediaPlaying(descriptor, state);
};
const fullscreenchange = () => { const fullscreenchange = () => {
state.interface.isFullscreen = !!document.fullscreenElement; state.interface.isFullscreen =
!!document.fullscreenElement || // other browsers
!!(document as any).webkitFullscreenElement; // safari
updateInterface(descriptor, state); updateInterface(descriptor, state);
}; };
const volumechange = async () => { const volumechange = async () => {
@@ -324,6 +335,7 @@ export function createVideoStateProvider(
player.addEventListener("timeupdate", timeupdate); player.addEventListener("timeupdate", timeupdate);
player.addEventListener("loadedmetadata", loadedmetadata); player.addEventListener("loadedmetadata", loadedmetadata);
player.addEventListener("canplay", canplay); player.addEventListener("canplay", canplay);
player.addEventListener("ratechange", ratechange);
fscreen.addEventListener("fullscreenchange", fullscreenchange); fscreen.addEventListener("fullscreenchange", fullscreenchange);
player.addEventListener("error", error); player.addEventListener("error", error);
player.addEventListener( player.addEventListener(

View File

@@ -22,14 +22,22 @@ export type VideoPlayerMeta = {
}[]; }[];
}; };
export enum VideoPlayerTimeFormat {
REGULAR = 0,
REMAINING = 1,
}
export type VideoPlayerState = { export type VideoPlayerState = {
// state related to the user interface // state related to the user interface
interface: { interface: {
isFullscreen: boolean; isFullscreen: boolean;
popout: string | null; // id of current popout (eg source select, episode select) 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) 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 leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
popoutBounds: null | DOMRect; // bounding box of current popout 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 // 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 isFirstLoading: boolean; // first buffering of the video, when set to false the video can start playing
hasPlayedOnce: boolean; // has the video played at all? hasPlayedOnce: boolean; // has the video played at all?
volume: number; volume: number;
playbackSpeed: number;
}; };
// state related to video progress // 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 { ArrowLink } from "@/components/text/ArrowLink";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
export function DeveloperView() { export default function DeveloperView() {
return ( return (
<div className="py-48"> <div className="py-48">
<Navigation /> <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 [embed, setEmbed] = useState<MWEmbed | null>(null);
const [embedScraperId, setEmbedScraperId] = useState<string | null>(null); const [embedScraperId, setEmbedScraperId] = useState<string | null>(null);
const embedScraper = useMemo( 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 [media, setMedia] = useState<DetailedMeta | null>(null);
const [providerId, setProviderId] = useState<string | null>(null); const [providerId, setProviderId] = useState<string | null>(null);

View File

@@ -1,4 +1,4 @@
// simple empty view, perfect for putting in tests // simple empty view, perfect for putting in tests
export function TestView() { export default function TestView() {
return <div />; 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 [video, setVideo] = useState<VideoData | null>(null);
const [videoType, setVideoType] = useState<MWStreamType>(MWStreamType.MP4); const [videoType, setVideoType] = useState<MWStreamType>(MWStreamType.MP4);
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
@@ -64,8 +64,9 @@ export function VideoTesterView() {
/> />
<SourceController <SourceController
source={video.streamUrl} source={video.streamUrl}
type={MWStreamType.MP4} type={videoType}
quality={MWStreamQuality.Q720P} quality={MWStreamQuality.QUNKNOWN}
captions={[]}
/> />
</VideoPlayer> </VideoPlayer>
</div> </div>

View File

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

View File

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

2870
yarn.lock

File diff suppressed because it is too large Load Diff