Compare commits

...

154 Commits
3.0.3 ... 3.0.8

Author SHA1 Message Date
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
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
mrjvs
0d088755ee Merge pull request #207 from movie-web/dev
Version 3.0.6
2023-03-13 21:53:23 +01:00
mrjvs
e5eb09af4d Merge branch 'master' into dev 2023-03-13 21:48:09 +01:00
mrjvs
0036c22970 version bump 2023-03-13 21:42:36 +01:00
mrjvs
8844efa754 Merge pull request #206 from movie-web/feature-small-features
Meta on window + show selected provider and embed
2023-03-13 21:41:20 +01:00
mrjvs
3c68794e5b Merge branch 'dev' into feature-small-features 2023-03-13 21:39:41 +01:00
mrjvs
5fc8355e8e add progress to window
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-03-13 21:27:40 +01:00
frost768
f2efd828dc forgot package.json, damnit 2023-03-13 23:25:42 +03:00
mrjvs
b36324d58e selected providers + meta data on window object + fix dev dependencies
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-03-13 21:25:28 +01: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
mrjvs
f7d51e6d8b Merge pull request #199 from frost768/dev
flixhq scraping improved
2023-03-13 19:41:17 +01:00
Emre Can Minnet
c5ff5817a4 Merge branch 'dev' into dev 2023-03-13 21:39:33 +03:00
frost768
3aa4365a56 'auto' quality removed 2023-03-13 21:37:29 +03:00
mrjvs
80a9f1c91b Merge pull request #200 from JipFr/dev
New popouts & other changes
2023-03-13 19:22:47 +01:00
frost768
f02256f9e0 enum value added 2023-03-13 16:48:28 +03:00
Jip Fr
ed5435f69e YEE 2 2023-03-12 23:23:55 +01:00
Jip Fr
b494469b71 Yee 2023-03-12 23:19:57 +01:00
Jip Fr
bbb9072bc9 Merge remote-tracking branch 'original/dev' into dev 2023-03-12 23:17:03 +01:00
Jip Fr
a34a644d07 Sort bookmarks based on last watch 2023-03-12 22:59:43 +01:00
Jip Fr
506c00960f Fix backdrop tap not working properly on mobile 2023-03-12 22:51:27 +01:00
Jip Fr
93fb343fa9 Don't toggle pause on right mouse click
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-03-12 22:23:14 +01:00
Jip Fr
5e8ad2e996 Localization, center loading, create divider action, rename season/episode route in EpisodeSelectionPopout 2023-03-12 22:19:13 +01:00
Jip Fr
c0867182d7 Add cool new popout stuff
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-03-12 21:49:58 +01:00
mrjvs
89f77debca fix v3 version popup 2023-03-12 19:36:54 +01:00
mrjvs
80f7240f58 fix bad sizing 2023-03-12 19:07:37 +01:00
Jip Fr
a520cf02bb Start new styling for popouts
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-03-12 18:48:46 +01:00
frost768
051c1ba709 flixhq scraping improved 2023-03-12 13:57:01 +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
mrjvs
1c77807987 Merge pull request #196 from movie-web/dev
V3.0.5
2023-03-10 21:21:59 +01:00
mrjvs
9bba47575a Merge branch 'master' into dev 2023-03-10 21:09:18 +01:00
mrjvs
dace2338be bump version 2023-03-10 21:06:03 +01:00
mrjvs
30d8e11992 Merge pull request #189 from lem6ns/external_ids
fix(meta): fallback to no "_latest"
2023-03-10 21:04:27 +01:00
mrjvs
9c9ce92681 Merge branch 'dev' into external_ids 2023-03-10 21:00:56 +01:00
mrjvs
30cc5aa78b fix more linting 2023-03-10 20:59:10 +01:00
mrjvs
ac28f32ef4 fix linting and make code nicer 2023-03-10 20:54:56 +01:00
mrjvs
fca9fea265 Merge pull request #194 from movie-web/feature-frame-protection
Add security headers
2023-03-10 20:45:43 +01:00
James Hawkins
c2bd7714ed Merge branch 'dev' into feature-frame-protection 2023-03-10 19:40:37 +00:00
mrjvs
48214af202 Merge pull request #175 from zisra/dev
Add Picture-in-picture
2023-03-10 20:29:55 +01:00
Emre Can Minnet
007375c1df Merge branch 'dev' into feat/subtitle-rendering 2023-03-10 22:27:30 +03:00
mrjvs
72ad53ee56 add security headers 2023-03-10 20:23:14 +01:00
mrjvs
02d94ba411 Merge branch 'dev' into dev 2023-03-10 19:49:51 +01:00
mrjvs
84913aa63d Merge branch 'dev' into external_ids 2023-03-10 19:48:58 +01:00
mrjvs
9d7ddc03a5 name annotation jobs 2023-03-10 19:41:32 +01:00
mrjvs
5327cbffaa update annotate download script to use v6 2023-03-10 19:38:59 +01:00
mrjvs
695ccef2b5 added yarn cache to deployment script 2023-03-10 19:35:51 +01:00
mrjvs
addd8ca031 fix wrong version 2023-03-10 19:34:25 +01:00
mrjvs
dd662efd72 Merge pull request #192 from movie-web/fix-ci-lineendings
update linting ci
2023-03-10 19:28:26 +01:00
mrjvs
900c70e36a update ci 2023-03-10 19:25:14 +01:00
mrjvs
68a1470447 seperate building and linting 2023-03-10 19:17:11 +01:00
mrjvs
b42d36c5ac fix lint errors 2023-03-10 19:12:22 +01:00
mrjvs
6b9774a210 update linting ci 2023-03-10 19:10:08 +01:00
James Hawkins
a5cd05b144 Merge branch 'dev' into external_ids 2023-03-10 07:09:31 +00:00
James Hawkins
bdb4b3507a Merge pull request #187 from lem6ns/dev
fix(netfilm): use different cdn
2023-03-10 07:08:23 +00:00
cloud
ca6383900a fix(meta): fallback to no "_latest" 2023-03-09 19:22:41 -07:00
cloud
5e97a195d9 fix: vscode settings file 2023-03-09 15:37:06 -07:00
cloud
25e32a14b7 feat(netfilm): add captions 2023-03-09 15:35:39 -07:00
cloud
139a760be0 fix(netfilm): use different cdn 2023-03-09 15:34:54 -07:00
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
zisra
a3e244285c mrvjs suggested changes 2023-03-04 10:24:56 -06:00
mrjvs
935cb2427b Merge pull request #178 from frost768/dev
feature: subtitle uploading
2023-03-04 15:41:43 +01:00
frost768
404cd897f3 feature: subtitle uploading 2023-03-03 19:33:30 +03:00
Jip Fr
f72d6db253 Floating popout router
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-02-28 23:36:46 +01:00
Jip Fr
b9a9db348b Move episodes over into new popout
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-02-28 21:32:03 +01:00
zisra
fac0a878f3 More fixes 2023-02-28 13:04:01 -06:00
zisra
596e680a18 TypeScript fix 2023-02-28 13:03:06 -06:00
mrjvs
cc51559c29 Floating component start 2023-02-28 19:26:46 +01:00
zisra
c6bf568514 Attempt to fix types 2023-02-28 11:26:30 -06:00
zisra
4a38c77e2d Fix feature detection 2023-02-27 17:44:50 -06:00
zisra
163ca0df29 Fix isPictureInPicture 2023-02-27 17:35:56 -06:00
Jip Fr
19d2b963a8 Add settings popout, add swipe stuff
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-02-27 20:30:06 +01:00
zisra
3fad6edaad Webkit support 2023-02-27 03:43:14 -06:00
zisra
f2f7925cbb CSS changes 2023-02-27 01:19:38 -06:00
zisra
b9026c50f5 Picture in picture 2023-02-27 00:58:47 -06:00
zisra
a1f3986e64 Picture in picture 2023-02-27 00:58:36 -06:00
mrjvs
224cdb6710 Merge pull request #172 from movie-web/dev
version 3.0.4
2023-02-24 23:22:48 +01:00
mrjvs
f76db3e4b7 Merge branch 'master' into dev 2023-02-24 23:18:37 +01:00
mrjvs
9abb009725 bump version 2023-02-24 23:14:27 +01:00
mrjvs
0ca4b3cf49 Merge pull request #171 from movie-web/feature-pwa
PWA
2023-02-24 23:11:01 +01:00
mrjvs
9418a7c45d Merge branch 'dev' into feature-pwa 2023-02-24 23:10:45 +01:00
mrjvs
d34d2c8ce0 review changes 2023-02-24 23:09:27 +01:00
mrjvs
281785a0ef Merge pull request #157 from zisra/dev
Download button
2023-02-24 22:35:46 +01:00
mrjvs
28c008a77f add any purpose 2023-02-24 22:20:35 +01:00
mrjvs
717ebbaeae maskable icon 2023-02-24 22:16:51 +01:00
mrjvs
f715f70f9e fix layout sizings
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-24 22:12:31 +01:00
mrjvs
24aeb68f55 error boundary
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-24 21:45:14 +01:00
zisra
8ed0d3740f Merge branch 'movie-web:dev' into dev 2023-02-24 13:32:47 -06:00
mrjvs
444c751b78 cache busting pwa 2023-02-24 20:12:20 +01:00
mrjvs
63b9adf7d8 disable gdriveplayer 2023-02-24 19:23:26 +01:00
mrjvs
3a1c3ad260 add PWA support 2023-02-24 19:23:00 +01:00
James Hawkins
e68fe0e115 Update netfilm.ts 2023-02-24 14:22:06 +00:00
James Hawkins
d51246120d Update flixhq.ts 2023-02-24 13:24:45 +00:00
James Hawkins
23b439ff79 Temporarily fix flixhq provider
This fix can be used whilst we wait for api.consumet.org to resolve their issues. See https://github.com/consumet/api.consumet.org/issues/326 for more information.
2023-02-24 13:06:05 +00:00
zisra
ac350f276c Merge branch 'movie-web:dev' into dev 2023-02-22 19:27:13 -06:00
mrjvs
854e6bede4 Merge pull request #169 from movie-web/feature-developer-tooling
Development tooling, round robin and better settings
2023-02-22 22:13:16 +01:00
mrjvs
25670814e4 fix tsconfig types 2023-02-22 22:08:11 +01:00
mrjvs
7c2ad68c2a add default for NORMAL_ROUTER setting 2023-02-22 21:54:02 +01:00
mrjvs
e82173efbe update script 2023-02-22 21:49:58 +01:00
mrjvs
485698a43c support for round robin proxies 2023-02-22 21:41:13 +01:00
mrjvs
444156236c add unit tests for providers 2023-02-22 21:15:37 +01:00
mrjvs
4f9ef382dc provider and embed scraper tools 2023-02-22 20:26:19 +01:00
mrjvs
cedc987509 Add developer video testing page 2023-02-22 19:02:23 +01:00
zisra
a99437b4cc Fix title 2023-02-21 15:07:40 -06:00
zisra
3696a05e1e Fix suggested changes 2023-02-21 14:17:36 -06:00
zisra
f5e5b48616 Update VideoPlayer.tsx 2023-02-20 20:28:09 -06:00
zisra
9ff49e42a3 Update Icon.tsx 2023-02-20 20:26:51 -06:00
zisra
d6a46e1cdc Update Icon.tsx 2023-02-20 20:23:06 -06:00
zisra
d10cbd5e9b Update VideoPlayer.tsx 2023-02-20 20:20:19 -06:00
zisra
1853c8eac7 Create DownloadAction.tsx 2023-02-20 20:18:38 -06:00
106 changed files with 6712 additions and 1281 deletions

View File

@@ -43,6 +43,7 @@ module.exports = {
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
"no-restricted-syntax": "off",
"import/no-unresolved": ["error", { ignore: ["^virtual:"] }],
"react/jsx-props-no-spreading": "off",
"consistent-return": "off",
"no-continue": "off",

1
.gitattributes vendored Normal file
View File

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

View File

@@ -18,12 +18,13 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'yarn'
- name: Install Yarn packages
run: yarn install
- name: Build project
run: npm run build
run: yarn build
- name: Upload production-ready build files
uses: actions/upload-artifact@v3

View File

@@ -5,8 +5,7 @@ on:
branches:
- master
- dev
pull_request_target:
types: [opened, reopened, synchronize]
pull_request:
jobs:
linting:
@@ -21,20 +20,30 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'yarn'
- name: Install Yarn packages
run: yarn install
- name: Run ESLint Report
run: yarn lint:report
# continue on error, so it still reports it in the next step
continue-on-error: true
- name: Run ESLint
run: yarn lint
- name: Annotate Code Linting Results
uses: ataylorme/eslint-annotate-action@v2
building:
name: Build project
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
report-json: "eslint_report.json"
node-version: 18
cache: 'yarn'
- name: Install Yarn packages
run: yarn install
- name: Build Project
run: npm run build
run: yarn build

1
.gitignore vendored
View File

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

View File

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

View File

@@ -6,16 +6,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Because watching movies legally is boring"
content="The place for your favourite movies & shows"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#E880C5" />
<meta name="msapplication-TileColor" content="#E880C5" />
<meta name="theme-color" content="#E880C5" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
<meta name="msapplication-TileColor" content="#120f1d" />
<meta name="theme-color" content="#120f1d" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -25,7 +24,7 @@
/>
<script src="config.js"></script>
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@3744edbc5f64a77985b6421ea5040e688663634b/out.js"></script>
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@8b821f445b83d51ef1b8f42c99b7346f6b47dce5/out.js"></script>
<!-- prevent darkreader extension from messing with our already dark site -->
<meta name="darkreader-lock" />

View File

@@ -1,13 +1,16 @@
{
"name": "movie-web",
"version": "3.0.3",
"version": "3.0.8",
"private": true,
"homepage": "https://movie.squeezebox.dev",
"dependencies": {
"@formkit/auto-animate": "^1.0.0-beta.5",
"@headlessui/react": "^1.5.0",
"@types/react-helmet": "^6.1.6",
"@react-spring/web": "^9.7.1",
"@use-gesture/react": "^10.2.24",
"core-js": "^3.29.1",
"crypto-js": "^4.1.1",
"dompurify": "^3.0.1",
"fscreen": "^1.2.0",
"fuse.js": "^6.4.6",
"hls.js": "^1.0.7",
@@ -16,6 +19,7 @@
"json5": "^2.2.0",
"lodash.throttle": "^4.1.1",
"nanoid": "^4.0.0",
"node-webvtt": "^1.9.4",
"ofetch": "^1.0.0",
"pako": "^2.1.0",
"react": "^17.0.2",
@@ -26,12 +30,14 @@
"react-router-dom": "^5.2.0",
"react-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5",
"react-use": "^17.4.0",
"srt-webvtt": "^2.0.0",
"unpacker": "^1.0.1"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"preview": "vite preview",
"lint": "eslint --ext .tsx,.ts src",
"lint:fix": "eslint --fix --ext .tsx,.ts src",
@@ -39,9 +45,8 @@
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
"defaults",
"chrome > 90"
],
"development": [
"last 1 chrome version",
@@ -50,22 +55,27 @@
]
},
"devDependencies": {
"@babel/core": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.21.0",
"@tailwindcss/line-clamp": "^0.4.2",
"@types/chromecast-caf-sender": "^1.0.5",
"@types/crypto-js": "^4.1.1",
"@types/dompurify": "^2.4.0",
"@types/fscreen": "^1.0.1",
"@types/lodash.throttle": "^4.1.7",
"@types/node": "^17.0.15",
"@types/pako": "^2.0.0",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@types/react-router": "^5.1.18",
"@types/react-helmet": "^6.1.6",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"@types/react-stickynode": "^4.0.0",
"@types/react-transition-group": "^4.4.5",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"@vitejs/plugin-react-swc": "^3.0.0",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.10.0",
"eslint-config-airbnb": "19.0.4",
@@ -76,6 +86,7 @@
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "7.29.4",
"eslint-plugin-react-hooks": "4.3.0",
"jsdom": "^21.1.0",
"postcss": "^8.4.20",
"prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.7",
@@ -84,6 +95,10 @@
"typescript": "^4.6.4",
"vite": "^4.0.1",
"vite-plugin-checker": "^0.5.6",
"vite-plugin-package-version": "^1.0.2"
"vite-plugin-package-version": "^1.0.2",
"vite-plugin-pwa": "^0.14.4",
"vitest": "^0.28.5",
"workbox-build": "^6.5.4",
"workbox-window": "^6.5.4"
}
}

5
public/_headers Normal file
View File

@@ -0,0 +1,5 @@
/*
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Referrer-Policy: origin-when-cross-origin

View File

@@ -3,7 +3,7 @@
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
<TileColor>#120f1d</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

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

1
public/ping.txt Normal file
View File

@@ -0,0 +1 @@
pong

View File

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

27
src/@types/node_webtt.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
declare module "node-webvtt" {
interface Cue {
identifier: string;
start: number;
end: number;
text: string;
styles: string;
}
interface Options {
meta?: boolean;
strict?: boolean;
}
type ParserError = Error;
interface ParseResult {
valid: boolean;
strict: boolean;
cues: Cue[];
errors: ParserError[];
meta?: Map<string, string>;
}
interface Segment {
duration: number;
cues: Cue[];
}
function parse(text: string, options: Options): ParseResult;
function segment(input: string, segmentLength?: number): Segment[];
}

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ registerEmbedScraper({
async getStream() {
// throw new Error("Oh well 2")
return {
embedId: "",
streamUrl: "",
quality: MWStreamQuality.Q1080P,
captions: [],

View File

@@ -3,7 +3,7 @@ import { registerEmbedScraper } from "@/backend/helpers/register";
import {
MWStreamQuality,
MWStreamType,
MWStream,
MWEmbedStream,
} from "@/backend/helpers/streams";
import { proxiedFetch } from "@/backend/helpers/fetch";
@@ -13,7 +13,7 @@ const URL_API = `${URL_BASE}/api`;
const URL_API_SOURCE = `${URL_API}/source`;
async function scrape(embed: string) {
const sources: MWStream[] = [];
const sources: MWEmbedStream[] = [];
const embedID = embed.split("/").pop();
@@ -28,6 +28,7 @@ async function scrape(embed: string) {
for (const stream of streams) {
sources.push({
embedId: "",
streamUrl: stream.file as string,
quality: stream.label as MWStreamQuality,
type: stream.type as MWStreamType,

View File

@@ -1,7 +1,10 @@
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import toWebVTT from "srt-webvtt";
import DOMPurify from "dompurify";
export const sanitize = DOMPurify.sanitize;
export const CUSTOM_CAPTION_ID = "customCaption";
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.type === MWCaptionType.SRT) {
let captionBlob: Blob;
@@ -32,3 +35,18 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
throw new Error("invalid type");
}
export async function convertCustomCaptionFileToWebVTT(file: File) {
const header = await file.slice(0, 6).text();
const isWebVTT = header === "WEBVTT";
if (!isWebVTT) {
return toWebVTT(file);
}
return URL.createObjectURL(file);
}
export function revokeCaptionBlob(url: string | undefined) {
if (url && url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}

View File

@@ -1,4 +1,4 @@
import { MWStream } from "./streams";
import { MWEmbedStream } from "./streams";
export enum MWEmbedType {
M4UFREE = "m4ufree",
@@ -23,5 +23,5 @@ export type MWEmbedScraper = {
rank: number;
disabled?: boolean;
getStream(ctx: MWEmbedContext): Promise<MWStream>;
getStream(ctx: MWEmbedContext): Promise<MWEmbedStream>;
};

View File

@@ -1,6 +1,15 @@
import { conf } from "@/setup/config";
import { ofetch } from "ofetch";
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
// round robins all proxy urls
function getProxyUrl(): string {
const url = conf().PROXY_URLS[proxyUrlIndex];
proxyUrlIndex = (proxyUrlIndex + 1) % conf().PROXY_URLS.length;
return url;
}
type P<T> = Parameters<typeof ofetch<T>>;
type R<T> = ReturnType<typeof ofetch<T>>;
@@ -41,7 +50,7 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
parsedUrl.searchParams.set(k, v);
});
return baseFetch<T>(conf().BASE_PROXY_URL, {
return baseFetch<T>(getProxyUrl(), {
...ops,
baseURL: undefined,
params: {

View File

@@ -43,7 +43,13 @@ async function findBestEmbedStream(
providerId: string,
ctx: MWProviderRunContext
): Promise<MWStream | null> {
if (result.stream) return result.stream;
if (result.stream) {
return {
...result.stream,
providerId,
embedId: providerId,
};
}
let embedNum = 0;
for (const embed of result.embeds) {
@@ -89,6 +95,7 @@ async function findBestEmbedStream(
type: "embed",
});
stream.providerId = providerId;
return stream;
}

View File

@@ -10,6 +10,7 @@ export enum MWCaptionType {
export enum MWStreamQuality {
Q360P = "360p",
Q540P = "540p",
Q480P = "480p",
Q720P = "720p",
Q1080P = "1080p",
@@ -27,5 +28,11 @@ export type MWStream = {
streamUrl: string;
type: MWStreamType;
quality: MWStreamQuality;
providerId?: string;
embedId?: string;
captions: MWCaption[];
};
export type MWEmbedStream = MWStream & {
embedId: string;
};

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,63 @@
import { compareTitle } from "@/utils/titleMatch";
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const flixHqBase = "https://api.consumet.org/movies/flixhq";
// const flixHqBase = "https://api.consumet.org/movies/flixhq";
// *** TEMPORARY FIX - use other instance
// SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326
const flixHqBase = "https://c.delusionz.xyz/movies/flixhq";
interface FLIXMediaBase {
id: number;
title: string;
url: string;
image: string;
type: "Movie" | "TV Series";
}
interface FLIXTVSerie extends FLIXMediaBase {
seasons: number | null;
}
interface FLIXMovie extends FLIXMediaBase {
releaseDate: string;
}
function castSubtitles({ url, lang }: { url: string; lang: string }) {
return {
url,
langIso: lang,
type:
url.substring(url.length - 3) === "vtt"
? MWCaptionType.VTT
: MWCaptionType.SRT,
};
}
const qualityMap: Record<string, MWStreamQuality> = {
"360": MWStreamQuality.Q360P,
"540": MWStreamQuality.Q540P,
"480": MWStreamQuality.Q480P,
"720": MWStreamQuality.Q720P,
"1080": MWStreamQuality.Q1080P,
};
registerProvider({
id: "flixhq",
displayName: "FlixHQ",
rank: 100,
type: [MWMediaType.MOVIE],
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, progress }) {
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
// search for relevant item
const searchResults = await proxiedFetch<any>(
`/${encodeURIComponent(media.meta.title)}`,
@@ -20,11 +65,28 @@ registerProvider({
baseURL: flixHqBase,
}
);
const foundItem = searchResults.results.find((v: any) => {
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
if (media.meta.type === MWMediaType.MOVIE) {
if (v.type !== "Movie") return false;
const movie = v as FLIXMovie;
return (
compareTitle(v.title, media.meta.title) &&
v.releaseDate === media.meta.year
compareTitle(movie.title, media.meta.title) &&
movie.releaseDate === media.meta.year
);
}
if (media.meta.type === MWMediaType.SERIES) {
if (v.type !== "TV Series") return false;
const serie = v as FLIXTVSerie;
if (serie.seasons && media.meta.seasons) {
return (
compareTitle(serie.title, media.meta.title) &&
serie.seasons === media.meta.seasons.length
);
}
return false;
}
return false;
});
if (!foundItem) throw new Error("No watchable item found");
const flixId = foundItem.id;
@@ -37,29 +99,47 @@ registerProvider({
id: flixId,
},
});
if (!mediaInfo.episodes) throw new Error("No watchable item found");
// get stream info from media
progress(75);
// By default we assume it is a movie
let episodeId: string | undefined = mediaInfo.episodes[0].id;
if (media.meta.type === MWMediaType.SERIES) {
const seasonNo = media.meta.seasonData.number;
const episodeNo = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
episodeId = mediaInfo.episodes.find(
(e: any) => e.season === seasonNo && e.number === episodeNo
)?.id;
}
if (!episodeId) throw new Error("No watchable item found");
const watchInfo = await proxiedFetch<any>("/watch", {
baseURL: flixHqBase,
params: {
episodeId: mediaInfo.episodes[0].id,
episodeId,
mediaId: flixId,
},
});
// get best quality source
const source = watchInfo.sources.reduce((p: any, c: any) =>
c.quality > p.quality ? c : p
);
if (!watchInfo.sources) throw new Error("No watchable item found");
// get best quality source
// comes sorted by quality in descending order
const source = watchInfo.sources[0];
return {
embeds: [],
stream: {
streamUrl: source.url,
quality: MWStreamQuality.QUNKNOWN,
quality: qualityMap[source.quality],
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
captions: [],
captions: watchInfo.subtitles
.filter(
(x: { url: string; lang: string }) => !x.lang.includes("(maybe)")
)
.map(castSubtitles),
},
};
},

View File

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

View File

@@ -1,17 +1,21 @@
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const netfilmBase = "https://net-film.vercel.app";
const qualityMap = {
"360": MWStreamQuality.Q360P,
"480": MWStreamQuality.Q480P,
"720": MWStreamQuality.Q720P,
"1080": MWStreamQuality.Q1080P,
const qualityMap: Record<number, MWStreamQuality> = {
360: MWStreamQuality.Q360P,
540: MWStreamQuality.Q540P,
480: MWStreamQuality.Q480P,
720: MWStreamQuality.Q720P,
1080: MWStreamQuality.Q1080P,
};
type QualityInMap = keyof typeof qualityMap;
registerProvider({
id: "netfilm",
@@ -20,6 +24,9 @@ registerProvider({
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
// search for relevant item
const searchResponse = await proxiedFetch<any>(
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
@@ -47,20 +54,29 @@ registerProvider({
}
);
const { qualities } = watchInfo.data;
const data = watchInfo.data;
// get best quality source
const source = qualities.reduce((p: any, c: any) =>
c.quality > p.quality ? c : p
const source: { url: string; quality: number } = data.qualities.reduce(
(p: any, c: any) => (c.quality > p.quality ? c : p)
);
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
needsProxy: false,
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
type: MWCaptionType.SRT,
langIso: sub.language,
}));
return {
embeds: [],
stream: {
streamUrl: source.url,
quality: qualityMap[source.quality as QualityInMap],
streamUrl: source.url
.replace("akm-cdn", "aws-cdn")
.replace("gg-cdn", "aws-cdn"),
quality: qualityMap[source.quality],
type: MWStreamType.HLS,
captions: [],
captions: mappedCaptions,
},
};
}
@@ -108,20 +124,29 @@ registerProvider({
}
);
const { qualities } = episodeStream.data;
const data = episodeStream.data;
// get best quality source
const source = qualities.reduce((p: any, c: any) =>
c.quality > p.quality ? c : p
const source: { url: string; quality: number } = data.qualities.reduce(
(p: any, c: any) => (c.quality > p.quality ? c : p)
);
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
needsProxy: false,
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
type: MWCaptionType.SRT,
langIso: sub.language,
}));
return {
embeds: [],
stream: {
streamUrl: source.url,
quality: qualityMap[source.quality as QualityInMap],
streamUrl: source.url
.replace("akm-cdn", "aws-cdn")
.replace("gg-cdn", "aws-cdn"),
quality: qualityMap[source.quality],
type: MWStreamType.HLS,
captions: [],
captions: mappedCaptions,
},
};
},

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

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

View File

@@ -34,6 +34,12 @@ export enum Icons {
CAPTIONS = "captions",
LINK = "link",
CASTING = "casting",
CIRCLE_EXCLAMATION = "circle_exclamation",
DOWNLOAD = "download",
GEAR = "gear",
WATCH_PARTY = "watch_party",
PICTURE_IN_PICTURE = "pictureInPicture",
CHECKMARK = "checkmark",
}
export interface IconProps {
@@ -72,9 +78,15 @@ const iconList: Record<Icons, string> = {
skip_forward: `<svg width="1em" height="1em" viewBox="0 0 26 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.3333 12.3333L16 7.66667M16 7.66667L11.3333 3M16 7.66667H6.66667C5.42899 7.66667 4.242 8.15833 3.36684 9.0335C2.49167 9.90867 2 11.0957 2 12.3333C2 13.571 2.49167 14.758 3.36684 15.6332C4.242 16.5083 5.42899 17 6.66667 17H9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16.5043 14.2727V23H14.6591V16.0241H14.608L12.6094 17.277V15.6406L14.7699 14.2727H16.5043ZM22.0004 23.1918C21.2674 23.1889 20.6367 23.0085 20.1083 22.6506C19.5827 22.2926 19.1779 21.7741 18.8938 21.0952C18.6126 20.4162 18.4734 19.5994 18.4762 18.6449C18.4762 17.6932 18.6168 16.8821 18.8981 16.2116C19.1822 15.5412 19.587 15.0312 20.1126 14.6818C20.641 14.3295 21.2702 14.1534 22.0004 14.1534C22.7305 14.1534 23.3583 14.3295 23.8839 14.6818C24.4123 15.0341 24.8185 15.5455 25.1026 16.2159C25.3867 16.8835 25.5273 17.6932 25.5245 18.6449C25.5245 19.6023 25.3825 20.4205 25.0984 21.0994C24.8171 21.7784 24.4137 22.2969 23.8881 22.6548C23.3626 23.0128 22.7333 23.1918 22.0004 23.1918ZM22.0004 21.6619C22.5004 21.6619 22.8995 21.4105 23.1978 20.9077C23.4961 20.4048 23.6438 19.6506 23.641 18.6449C23.641 17.983 23.5728 17.4318 23.4364 16.9915C23.3029 16.5511 23.1126 16.2202 22.8654 15.9986C22.6211 15.777 22.3327 15.6662 22.0004 15.6662C21.5032 15.6662 21.1055 15.9148 20.8072 16.4119C20.5089 16.9091 20.3583 17.6534 20.3555 18.6449C20.3555 19.3153 20.4222 19.875 20.5558 20.3239C20.6921 20.7699 20.8839 21.1051 21.131 21.3295C21.3782 21.5511 21.668 21.6619 22.0004 21.6619Z" fill="currentColor" /></svg>`,
skip_backward: `<svg width="1em" height="1em" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`,
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`,
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 25 20"><path transform="translate(-3 -6)" d="M25.5,6H5.5A2.507,2.507,0,0,0,3,8.5v15A2.507,2.507,0,0,0,5.5,26h20A2.507,2.507,0,0,0,28,23.5V8.5A2.507,2.507,0,0,0,25.5,6ZM5.5,16h5v2.5h-5ZM18,23.5H5.5V21H18Zm7.5,0h-5V21h5Zm0-5H13V16H25.5Z" fill="currentColor"/></svg>`,
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
casting: "",
download: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
gear: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>`,
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`,
pictureInPicture: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/></svg>`,
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
};
function ChromeCastButton() {

View File

@@ -4,7 +4,13 @@ import {
TransitionClasses,
} from "@headlessui/react";
type TransitionAnimations = "slide-down" | "slide-up" | "fade" | "none";
type TransitionAnimations =
| "slide-down"
| "slide-full-left"
| "slide-full-right"
| "slide-up"
| "fade"
| "none";
interface Props {
show?: boolean;
@@ -41,6 +47,28 @@ function getClasses(
};
}
if (animation === "slide-full-left") {
return {
leave: `transition-[transform] ${duration}`,
leaveFrom: "translate-x-0",
leaveTo: "-translate-x-full",
enter: `transition-[transform] ${duration}`,
enterFrom: "-translate-x-full",
enterTo: "translate-x-0",
};
}
if (animation === "slide-full-right") {
return {
leave: `transition-[transform] ${duration}`,
leaveFrom: "translate-x-0",
leaveTo: "translate-x-full",
enter: `transition-[transform] ${duration}`,
enterFrom: "translate-x-full",
enterTo: "translate-x-0",
};
}
if (animation === "fade") {
return {
leave: `transition-[transform,opacity] ${duration}`,

View File

@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { conf } from "@/setup/config";
import { useBannerSize } from "@/hooks/useBanner";
import { BrandPill } from "./BrandPill";
export interface NavigationProps {
@@ -11,8 +12,15 @@ export interface NavigationProps {
}
export function Navigation(props: NavigationProps) {
const bannerHeight = useBannerSize();
return (
<div className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent">
<div
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
style={{
top: `${bannerHeight}px`,
}}
>
<div className="fixed left-0 right-0 flex items-center justify-between py-5 px-7">
<div
className={`${

View File

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

View File

@@ -0,0 +1,47 @@
import { ReactNode, useEffect, useRef } from "react";
export function createFloatingAnchorEvent(id: string): string {
return `__floating::anchor::${id}`;
}
interface Props {
id: string;
children?: ReactNode;
}
export function FloatingAnchor(props: Props) {
const ref = useRef<HTMLDivElement>(null);
const old = useRef<string | null>(null);
useEffect(() => {
if (!ref.current) return;
let cancelled = false;
function render() {
if (cancelled) return;
if (ref.current) {
const current = old.current;
const newer = ref.current.getBoundingClientRect();
const newerStr = JSON.stringify(newer);
if (current !== newerStr) {
old.current = newerStr;
const evtStr = createFloatingAnchorEvent(props.id);
(window as any)[evtStr] = newer;
const evObj = new CustomEvent(createFloatingAnchorEvent(props.id), {
detail: newer,
});
document.dispatchEvent(evObj);
}
}
window.requestAnimationFrame(render);
}
window.requestAnimationFrame(render);
return () => {
cancelled = true;
};
}, [props]);
return <div ref={ref}>{props.children}</div>;
}

View File

@@ -0,0 +1,189 @@
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
import { useIsMobile } from "@/hooks/useIsMobile";
import { PopoutSection } from "@/video/components/popouts/PopoutUtils";
import { useSpringValue, animated, easings } from "@react-spring/web";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { Icon, Icons } from "../Icon";
import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle";
interface FloatingCardProps {
children?: ReactNode;
onClose?: () => void;
for: string;
}
interface RootFloatingCardProps extends FloatingCardProps {
className?: string;
}
function CardBase(props: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const { isMobile } = useIsMobile();
const height = useSpringValue(0, {
config: { easing: easings.easeInOutSine, duration: 300 },
});
const width = useSpringValue(0, {
config: { easing: easings.easeInOutSine, duration: 300 },
});
const [pages, setPages] = useState<NodeListOf<Element> | null>(null);
const getNewHeight = useCallback(
(updateList = true) => {
if (!ref.current) return;
const children = ref.current.querySelectorAll(
":scope *[data-floating-page='true']"
);
if (updateList) setPages(children);
if (children.length === 0) {
height.start(0);
width.start(0);
return;
}
const lastChild = children[children.length - 1];
const rect = lastChild.getBoundingClientRect();
const rectHeight = lastChild.scrollHeight;
if (height.get() === 0) {
height.set(rectHeight);
width.set(rect.width);
} else {
height.start(rectHeight);
width.start(rect.width);
}
},
[height, width]
);
useEffect(() => {
if (!ref.current) return;
getNewHeight();
const observer = new MutationObserver(() => {
getNewHeight();
});
observer.observe(ref.current, {
attributes: false,
childList: true,
subtree: false,
});
return () => {
observer.disconnect();
};
}, [getNewHeight]);
useEffect(() => {
const observer = new ResizeObserver(() => {
getNewHeight(false);
});
pages?.forEach((el) => observer.observe(el));
return () => {
observer.disconnect();
};
}, [pages, getNewHeight]);
return (
<animated.div
ref={ref}
style={{
height,
width: isMobile ? "100%" : width,
}}
className="relative flex items-center justify-center overflow-hidden"
>
{props.children}
</animated.div>
);
}
export function FloatingCard(props: RootFloatingCardProps) {
const { isMobile } = useIsMobile();
const content = <CardBase>{props.children}</CardBase>;
if (isMobile)
return (
<FloatingCardMobilePosition
className={props.className}
onClose={props.onClose}
>
{content}
</FloatingCardMobilePosition>
);
return (
<FloatingCardAnchorPosition id={props.for} className={props.className}>
{content}
</FloatingCardAnchorPosition>
);
}
export function PopoutFloatingCard(props: FloatingCardProps) {
return (
<FloatingCard
className="overflow-hidden rounded-md bg-ash-300"
{...props}
/>
);
}
export const FloatingCardView = {
Header(props: {
title: string;
description: string;
close?: boolean;
goBack: () => any;
action?: React.ReactNode;
backText?: string;
}) {
let left = (
<div
onClick={props.goBack}
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<Icon icon={Icons.ARROW_LEFT} />
<span>{props.backText || "Go back"}</span>
</div>
);
if (props.close)
left = (
<div
onClick={props.goBack}
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<Icon icon={Icons.X} />
<span>Close</span>
</div>
);
return (
<div className="flex flex-col bg-[#1C161B]">
<FloatingDragHandle />
<PopoutSection>
<div className="flex justify-between">
<div>{left}</div>
<div>{props.action ?? null}</div>
</div>
<h2 className="mt-8 mb-2 text-3xl font-bold text-white">
{props.title}
</h2>
<p>{props.description}</p>
</PopoutSection>
</div>
);
},
Content(props: { children: React.ReactNode; noSection?: boolean }) {
return (
<div className="grid h-full grid-rows-[1fr]">
{props.noSection ? (
<div className="relative h-full overflow-y-auto bg-ash-300">
{props.children}
</div>
) : (
<PopoutSection className="relative h-full overflow-y-auto bg-ash-300">
{props.children}
</PopoutSection>
)}
<MobilePopoutSpacer />
</div>
);
},
};

View File

@@ -0,0 +1,75 @@
import { Transition } from "@/components/Transition";
import React, {
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
interface Props {
children?: ReactNode;
onClose?: () => void;
show?: boolean;
darken?: boolean;
}
export function FloatingContainer(props: Props) {
const [portalElement, setPortalElement] = useState<Element | null>(null);
const ref = useRef<HTMLDivElement>(null);
const target = useRef<Element | null>(null);
useEffect(() => {
function listen(e: MouseEvent) {
target.current = e.target as Element;
}
document.addEventListener("mousedown", listen);
return () => {
document.removeEventListener("mousedown", listen);
};
});
const click = useCallback(
(e: React.MouseEvent) => {
const startedTarget = target.current;
target.current = null;
if (e.currentTarget !== e.target) return;
if (!startedTarget) return;
if (!startedTarget.isEqualNode(e.currentTarget as Element)) return;
if (props.onClose) props.onClose();
},
[props]
);
useEffect(() => {
const element = ref.current?.closest(".popout-location");
setPortalElement(element ?? document.body);
}, []);
return (
<div ref={ref}>
{portalElement
? createPortal(
<Transition show={props.show} animation="none">
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none">
<Transition animation="fade" isChild>
<div
onClick={click}
className={[
"absolute inset-0",
props.darken ? "bg-black opacity-90" : "",
].join(" ")}
/>
</Transition>
<Transition animation="slide-up" className="h-0" isChild>
{props.children}
</Transition>
</div>
</Transition>,
portalElement
)
: null}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { useIsMobile } from "@/hooks/useIsMobile";
export function FloatingDragHandle() {
const { isMobile } = useIsMobile();
if (!isMobile) return null;
return (
<div className="relative z-50 mx-auto my-3 -mb-3 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" />
);
}
export function MobilePopoutSpacer() {
const { isMobile } = useIsMobile();
if (!isMobile) return null;
return <div className="h-[200px]" />;
}

View File

@@ -0,0 +1,39 @@
import { Transition } from "@/components/Transition";
import { useIsMobile } from "@/hooks/useIsMobile";
import { ReactNode } from "react";
interface Props {
children?: ReactNode;
show?: boolean;
className?: string;
height?: number;
width?: number;
active?: boolean; // true if a child view is loaded
}
export function FloatingView(props: Props) {
const { isMobile } = useIsMobile();
const width = !isMobile ? `${props.width}px` : "100%";
return (
<Transition
animation={props.active ? "slide-full-left" : "slide-full-right"}
className="absolute inset-0"
durationClass="duration-[400ms]"
show={props.show}
>
<div
className={[
props.className ?? "",
"grid grid-rows-[auto,minmax(0,1fr)]",
].join(" ")}
data-floating-page={props.show ? "true" : undefined}
style={{
height: props.height ? `${props.height}px` : undefined,
width: props.width ? width : undefined,
}}
>
{props.children}
</div>
</Transition>
);
}

View File

@@ -0,0 +1,80 @@
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
interface AnchorPositionProps {
children?: ReactNode;
id: string;
className?: string;
}
export function FloatingCardAnchorPosition(props: AnchorPositionProps) {
const ref = useRef<HTMLDivElement>(null);
const [left, setLeft] = useState<number>(0);
const [top, setTop] = useState<number>(0);
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
const calculateAndSetCoords = useCallback(
(anchor: DOMRect, card: DOMRect) => {
const buttonCenter = anchor.left + anchor.width / 2;
const bottomReal = window.innerHeight - anchor.bottom;
setTop(
window.innerHeight - bottomReal - anchor.height - card.height - 30
);
setLeft(
Math.min(
buttonCenter - card.width / 2,
window.innerWidth - card.width - 30
)
);
},
[]
);
useEffect(() => {
if (!anchorRect || !cardRect) return;
calculateAndSetCoords(anchorRect, cardRect);
}, [anchorRect, calculateAndSetCoords, cardRect]);
useEffect(() => {
if (!ref.current) return;
function checkBox() {
const divRect = ref.current?.getBoundingClientRect();
setCardRect(divRect ?? null);
}
checkBox();
const observer = new ResizeObserver(checkBox);
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, []);
useEffect(() => {
const evtStr = createFloatingAnchorEvent(props.id);
if ((window as any)[evtStr]) setAnchorRect((window as any)[evtStr]);
function listen(ev: CustomEvent<DOMRect>) {
setAnchorRect(ev.detail);
}
document.addEventListener(evtStr, listen as any);
return () => {
document.removeEventListener(evtStr, listen as any);
};
}, [props.id]);
return (
<div
ref={ref}
style={{
transform: `translateX(${left}px) translateY(${top}px)`,
}}
className={[
"pointer-events-auto z-10 inline-block origin-top-left touch-none overflow-hidden",
props.className ?? "",
].join(" ")}
>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useSpring, animated, config } from "@react-spring/web";
import { useDrag } from "@use-gesture/react";
import { ReactNode, useEffect, useRef, useState } from "react";
interface MobilePositionProps {
children?: ReactNode;
className?: string;
onClose?: () => void;
}
export function FloatingCardMobilePosition(props: MobilePositionProps) {
const ref = useRef<HTMLDivElement>(null);
const closing = useRef<boolean>(false);
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
const [{ y }, api] = useSpring(() => ({
y: 0,
onRest() {
if (!closing.current) return;
if (props.onClose) props.onClose();
},
}));
const bind = useDrag(
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => {
if (closing.current) return;
const height = cardRect?.height ?? 0;
if (last) {
// if past half height downwards
// OR Y velocity is past 0.5 AND going down AND 20 pixels below start position
if (my > height * 0.5 || (vy > 0.5 && dy > 0 && my > 20)) {
api.start({
y: height * 1.2,
immediate: false,
config: { ...config.wobbly, velocity: vy, clamp: true },
});
closing.current = true;
} else {
api.start({
y: 0,
immediate: false,
config: config.wobbly,
});
}
} else {
api.start({ y: my, immediate: true });
}
},
{
from: () => [0, y.get()],
filterTaps: true,
bounds: { top: 0 },
rubberband: true,
}
);
useEffect(() => {
if (!ref.current) return;
function checkBox() {
const divRect = ref.current?.getBoundingClientRect();
setCardRect(divRect ?? null);
}
checkBox();
const observer = new ResizeObserver(checkBox);
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, []);
return (
<div
className="absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
style={{
transform: `translateY(${
window.innerHeight - (cardRect?.height ?? 0) + 200
}px)`,
}}
>
<animated.div
ref={ref}
className={[props.className ?? "", "touch-none"].join(" ")}
style={{
y,
}}
{...bind()}
>
{props.children}
</animated.div>
</div>
);
}

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

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

View File

@@ -0,0 +1,60 @@
import { useLayoutEffect, useState } from "react";
export function useFloatingRouter(initial = "/") {
const [route, setRoute] = useState<string[]>(
initial.split("/").filter((v) => v.length > 0)
);
const [previousRoute, setPreviousRoute] = useState(route);
const currentPage = route[route.length - 1] ?? "/";
useLayoutEffect(() => {
if (previousRoute.length === route.length) return;
// when navigating backwards, we delay the updating by a bit so transitions can be applied correctly
setTimeout(() => {
setPreviousRoute(route);
}, 20);
}, [route, previousRoute]);
function navigate(path: string) {
const newRoute = path.split("/").filter((v) => v.length > 0);
if (newRoute.length > previousRoute.length) setPreviousRoute(newRoute);
setRoute(newRoute);
}
function isActive(page: string) {
if (page === "/") return true;
const index = previousRoute.indexOf(page);
if (index === -1) return false; // not active
if (index === previousRoute.length - 1) return false; // active but latest route so shouldnt be counted as active
return true;
}
function isCurrentPage(page: string) {
return page === currentPage;
}
function isLoaded(page: string) {
if (page === "/") return true;
return route.includes(page);
}
function pageProps(page: string) {
return {
show: isCurrentPage(page),
active: isActive(page),
};
}
function reset() {
navigate("/");
}
return {
navigate,
reset,
isLoaded,
isCurrentPage,
pageProps,
isActive,
};
}

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

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

View File

@@ -1,8 +1,11 @@
import React, { ReactNode, Suspense } from "react";
import "core-js/stable";
import React, { Suspense } from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, HashRouter } from "react-router-dom";
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import { conf } from "@/setup/config";
import { registerSW } from "virtual:pwa-register";
import App from "@/setup/App";
import "@/setup/ga";
@@ -16,9 +19,12 @@ import { initializeStores } from "./utils/storage";
const key =
(window as any)?.__CONFIG__?.VITE_KEY ?? import.meta.env.VITE_KEY ?? null;
if (key) {
(window as any).initMW(conf().BASE_PROXY_URL, key);
(window as any).initMW(conf().PROXY_URLS, key);
}
initializeChromecast();
registerSW({
immediate: true,
});
const LazyLoadedApp = React.lazy(async () => {
await initializeStores();

View File

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

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

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

View File

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

View File

@@ -1,4 +1,4 @@
export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
export const APP_VERSION = "3.0.3";
export const GA_ID = "G-44YVXRL61C";

View File

@@ -4,12 +4,13 @@
html,
body {
@apply bg-denim-100 text-denim-700 font-open-sans overflow-x-hidden;
@apply bg-denim-100 font-open-sans text-denim-700 overflow-x-hidden;
min-height: 100vh;
min-height: 100dvh;
}
html[data-full], html[data-full] body {
html[data-full],
html[data-full] body {
overscroll-behavior-y: none;
}
@@ -53,3 +54,122 @@ body[data-no-select] {
.google-cast-button:not(.casting) google-cast-launcher {
@apply brightness-[500];
}
/*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;
}

View File

@@ -60,19 +60,39 @@
"buttons": {
"episodes": "Episodes",
"source": "Source",
"captions": "Captions"
"captions": "Captions",
"download": "Download",
"settings": "Settings",
"pictureInPicture": "Picture in Picture"
},
"popouts": {
"sources": "Sources",
"seasons": "Seasons",
"captions": "Captions",
"captionPreferences": {
"title": "Customize",
"delay": "Delay",
"fontSize": "Size",
"opacity": "Opacity",
"color": "Color"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "No captions",
"linkedCaptions": "Linked captions",
"customCaption": "Custom caption",
"uploadCustomCaption": "Upload caption (SRT, VTT)",
"noEmbeds": "No embeds were found for this source",
"errors": {
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
"embedsError": "Something went wrong loading the embeds for this thing that you like"
},
"descriptions": {
"sources": "What provider do you want to use?",
"embeds": "Choose which video to view",
"seasons": "Choose which season you want to watch",
"episode": "Pick an episode",
"captions": "Choose a subtitle language",
"captionPreferences": "Make subtitles look how you want it"
}
},
"errors": {
@@ -88,5 +108,8 @@
},
"casting": {
"casting": "Casting to device..."
},
"errors": {
"offline": "Check your internet connection"
}
}

View File

@@ -0,0 +1,78 @@
import { useStore } from "@/utils/storage";
import { createContext, ReactNode, useContext, useMemo } from "react";
import { SettingsStore } from "./store";
import { MWSettingsData } from "./types";
interface MWSettingsDataSetters {
setLanguage(language: string): void;
setCaptionDelay(delay: number): void;
setCaptionColor(color: string): void;
setCaptionFontSize(size: number): void;
setCaptionBackgroundColor(backgroundColor: string): 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,
};
});
},
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 = backgroundColor;
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,22 @@
import { createVersionedStore } from "@/utils/storage";
import { MWSettingsData } from "./types";
export const SettingsStore = createVersionedStore<MWSettingsData>()
.setKey("mw-settings")
.addVersion({
version: 0,
create(): MWSettingsData {
return {
language: "en",
captionSettings: {
delay: 0,
style: {
color: "#ffffff",
fontSize: 25,
backgroundColor: "#00000096",
},
},
};
},
})
.build();

View File

@@ -0,0 +1,21 @@
export interface CaptionStyleSettings {
color: string;
/**
* Range is [10, 30]
*/
fontSize: number;
backgroundColor: string;
}
export interface CaptionSettings {
/**
* Range is [-10, 10]s
*/
delay: number;
style: CaptionStyleSettings;
}
export interface MWSettingsData {
language: string;
captionSettings: CaptionSettings;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,10 +10,7 @@ import { MobileCenterAction } from "@/video/components/actions/MobileCenterActio
import { PageTitleAction } from "@/video/components/actions/PageTitleAction";
import { PauseAction } from "@/video/components/actions/PauseAction";
import { ProgressAction } from "@/video/components/actions/ProgressAction";
import { QualityDisplayAction } from "@/video/components/actions/QualityDisplayAction";
import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction";
import { SourceSelectionAction } from "@/video/components/actions/SourceSelectionAction";
import { CaptionsSelectionAction } from "@/video/components/actions/CaptionsSelectionAction";
import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction";
import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction";
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
@@ -30,6 +27,10 @@ import { ReactNode, useCallback, useState } from "react";
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
import { SettingsAction } from "./actions/SettingsAction";
import { DividerAction } from "./actions/DividerAction";
type Props = VideoPlayerBaseProps;
@@ -120,6 +121,7 @@ export function VideoPlayer(props: Props) {
<HeaderAction
showControls={isMobile}
onClick={props.onGoBack}
isFullScreen
/>
</Transition>
<Transition
@@ -141,9 +143,9 @@ export function VideoPlayer(props: Props) {
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center">
<div />
<div className="flex items-center justify-center">
<CaptionsSelectionAction />
<SeriesSelectionAction />
<SourceSelectionAction />
<PictureInPictureAction />
<SettingsAction />
</div>
<FullscreenAction />
</div>
@@ -151,13 +153,12 @@ export function VideoPlayer(props: Props) {
<>
<LeftSideControls />
<div className="flex-1" />
<QualityDisplayAction />
<SeriesSelectionAction />
<SourceSelectionAction />
<div className="mx-2 h-6 w-px bg-white opacity-50" />
<DividerAction />
<SettingsAction />
<ChromecastAction />
<AirplayAction />
<CaptionsSelectionAction />
<PictureInPictureAction />
<FullscreenAction />
</>
)}
@@ -165,6 +166,7 @@ export function VideoPlayer(props: Props) {
</Transition>
{show ? <PopoutProviderAction /> : null}
</BackdropAction>
<CaptionRendererAction isControlsShown={show} />
{props.children}
</VideoPlayerError>
</>

View File

@@ -8,6 +8,7 @@ import {
useVideoPlayerDescriptor,
VideoPlayerContextProvider,
} from "../state/hooks";
import { MetaAction } from "./actions/MetaAction";
import { VideoElementInternal } from "./internal/VideoElementInternal";
export interface VideoPlayerBaseProps {
@@ -27,7 +28,9 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
const children =
typeof props.children === "function"
? props.children({ isFullscreen: videoInterface.isFullscreen })
? props.children({
isFullscreen: videoInterface.isFullscreen,
})
: props.children;
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
@@ -36,12 +39,13 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
<div
ref={ref}
className={[
"is-video-player relative h-full w-full select-none overflow-hidden bg-black",
"is-video-player popout-location relative h-full w-full select-none overflow-hidden bg-black",
props.includeSafeArea || videoInterface.isFullscreen
? "[border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]"
: "",
].join(" ")}
>
<MetaAction />
<VideoElementInternal autoPlay={props.autoPlay} />
<CastingInternal />
<WrapperRegisterInternal wrapper={ref.current} />

View File

@@ -19,8 +19,20 @@ export function BackdropAction(props: BackdropActionProps) {
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const clickareaRef = useRef<HTMLDivElement>(null);
const lastTouchEnd = useRef<number>(0);
const handleMouseMove = useCallback(() => {
if (!moved) setMoved(true);
if (!moved) {
setTimeout(() => {
const isTouch = Date.now() - lastTouchEnd.current < 200;
if (!isTouch) {
setMoved(true);
}
}, 20);
return;
}
// remove after all
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
if (moved) setMoved(false);
@@ -32,8 +44,6 @@ export function BackdropAction(props: BackdropActionProps) {
setMoved(false);
}, [setMoved]);
const [lastTouchEnd, setLastTouchEnd] = useState(0);
const handleClick = useCallback(
(
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
@@ -43,13 +53,17 @@ export function BackdropAction(props: BackdropActionProps) {
if (videoInterface.popout !== null) return;
if ((e as React.TouchEvent).type === "touchend") {
setLastTouchEnd(Date.now());
lastTouchEnd.current = Date.now();
return;
}
if ((e as React.MouseEvent<HTMLDivElement>).button !== 0) {
return; // not main button (left click), exit event
}
setTimeout(() => {
if (Date.now() - lastTouchEnd < 200) {
setMoved(!moved);
if (Date.now() - lastTouchEnd.current < 200) {
setMoved((v) => !v);
return;
}
@@ -57,7 +71,7 @@ export function BackdropAction(props: BackdropActionProps) {
else controls.play();
}, 20);
},
[controls, mediaPlaying, videoInterface, lastTouchEnd, moved]
[controls, mediaPlaying, videoInterface]
);
const handleDoubleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {

View File

@@ -0,0 +1,92 @@
import { Transition } from "@/components/Transition";
import { useSettings } from "@/state/settings";
import { sanitize } from "@/backend/helpers/captions";
import { parse, Cue } from "node-webvtt";
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";
function CaptionCue({ text }: { text?: string }) {
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,
}}
>
<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<Cue[]>([]);
useAsync(async () => {
const url = source?.caption?.url;
if (url) {
// Is there a better way?
const result = await fetch(url);
// Uses UTF-8 by default
const text = await result.text();
captions.current = parse(text, { strict: false }).cues;
} else {
captions.current = [];
}
}, [source?.caption?.url]);
if (!captions.current.length) return null;
const isVisible = (start: number, end: number): boolean => {
const delayedStart = start + captionSettings.delay;
const delayedEnd = end + 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(
({ identifier, end, start, text }) =>
isVisible(start, end) && (
<CaptionCue key={identifier || `${start}-${end}`} text={text} />
)
)}
</Transition>
);
}

View File

@@ -1,34 +0,0 @@
import { Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
import { useControls } from "@/video/state/logic/controls";
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
interface Props {
className?: string;
}
export function CaptionsSelectionAction(props: Props) {
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const { isMobile } = useIsMobile();
return (
<div className={props.className}>
<div className="relative">
<PopoutAnchor for="captions">
<VideoPlayerIconButton
className={props.className}
text={isMobile ? (t("videoPlayer.buttons.captions") as string) : ""}
wide={isMobile}
onClick={() => controls.openPopout("captions")}
icon={Icons.CAPTIONS}
/>
</PopoutAnchor>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta";
import { MWMediaType } from "@/backend/metadata/types";
export function DividerAction() {
const descriptor = useVideoPlayerDescriptor();
const meta = useMeta(descriptor);
if (meta?.meta.meta.type !== MWMediaType.SERIES) return null;
return <div className="mx-2 h-6 w-px bg-white opacity-50" />;
}

View File

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

View File

@@ -0,0 +1,59 @@
import { MWCaption } from "@/backend/helpers/streams";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta";
import { useProgress } from "@/video/state/logic/progress";
import { useEffect } from "react";
export type WindowMeta = {
meta: DetailedMeta;
captions: MWCaption[];
episode?: {
episodeId: string;
seasonId: string;
};
seasons?: {
id: string;
number: number;
title: string;
episodes?: { id: string; number: number; title: string }[];
}[];
progress: {
time: number;
duration: number;
};
} | null;
declare global {
interface Window {
meta?: Record<string, WindowMeta>;
}
}
export function MetaAction() {
const descriptor = useVideoPlayerDescriptor();
const meta = useMeta(descriptor);
const progress = useProgress(descriptor);
useEffect(() => {
if (!window.meta) window.meta = {};
if (meta) {
window.meta[descriptor] = {
meta: meta.meta,
captions: meta.captions,
seasons: meta.seasons,
episode: meta.episode,
progress: {
time: progress.time,
duration: progress.duration,
},
};
}
return () => {
if (window.meta) delete window.meta[descriptor];
};
}, [meta, descriptor, progress]);
return null;
}

View File

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

View File

@@ -4,9 +4,9 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta";
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
import { useControls } from "@/video/state/logic/controls";
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
import { useInterface } from "@/video/state/logic/interface";
import { useTranslation } from "react-i18next";
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
interface Props {
className?: string;
@@ -24,7 +24,7 @@ export function SeriesSelectionAction(props: Props) {
return (
<div className={props.className}>
<div className="relative">
<PopoutAnchor for="episodes">
<FloatingAnchor id="episodes">
<VideoPlayerIconButton
active={videoInterface.popout === "episodes"}
icon={Icons.EPISODES}
@@ -32,7 +32,7 @@ export function SeriesSelectionAction(props: Props) {
wide
onClick={() => controls.openPopout("episodes")}
/>
</PopoutAnchor>
</FloatingAnchor>
</div>
</div>
);

View File

@@ -2,33 +2,38 @@ import { Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
import { useControls } from "@/video/state/logic/controls";
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
import { useInterface } from "@/video/state/logic/interface";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
interface Props {
className?: string;
}
export function SourceSelectionAction(props: Props) {
export function SettingsAction(props: Props) {
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const videoInterface = useInterface(descriptor);
const controls = useControls(descriptor);
const videoInterface = useInterface(descriptor);
const { isMobile } = useIsMobile(false);
return (
<div className={props.className}>
<div className="relative">
<PopoutAnchor for="source">
<FloatingAnchor id="settings">
<VideoPlayerIconButton
active={videoInterface.popout === "source"}
icon={Icons.CLAPPER_BOARD}
iconSize="text-xl"
text={t("videoPlayer.buttons.source") as string}
wide
onClick={() => controls.openPopout("source")}
active={videoInterface.popout === "settings"}
className={props.className}
onClick={() => controls.openPopout("settings")}
text={
isMobile
? (t("videoPlayer.buttons.settings") as string)
: undefined
}
icon={Icons.GEAR}
/>
</PopoutAnchor>
</FloatingAnchor>
</div>
</div>
);

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 CaptionsSelectionAction(props: Props) {
const { t } = useTranslation();
return (
<PopoutListAction icon={Icons.CAPTIONS} onClick={props.onClick}>
{t("videoPlayer.buttons.captions")}
</PopoutListAction>
);
}

View File

@@ -0,0 +1,31 @@
import { Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useSource } from "@/video/state/logic/source";
import { MWStreamType } from "@/backend/helpers/streams";
import { normalizeTitle } from "@/utils/normalizeTitle";
import { useTranslation } from "react-i18next";
import { useMeta } from "@/video/state/logic/meta";
import { PopoutListAction } from "../../popouts/PopoutUtils";
export function DownloadAction() {
const descriptor = useVideoPlayerDescriptor();
const sourceInterface = useSource(descriptor);
const { t } = useTranslation();
const meta = useMeta(descriptor);
const isHLS = sourceInterface.source?.type === MWStreamType.HLS;
if (isHLS) return null;
const title = meta?.meta.meta.title;
return (
<PopoutListAction
href={isHLS ? undefined : sourceInterface.source?.url}
download={title ? `${normalizeTitle(title)}.mp4` : undefined}
icon={Icons.DOWNLOAD}
>
{t("videoPlayer.buttons.download")}
</PopoutListAction>
);
}

View File

@@ -0,0 +1,23 @@
import { Icons } from "@/components/Icon";
import { useTranslation } from "react-i18next";
import { PopoutListAction } from "../../popouts/PopoutUtils";
import { QualityDisplayAction } from "./QualityDisplayAction";
interface Props {
onClick?: () => any;
}
export function SourceSelectionAction(props: Props) {
const { t } = useTranslation();
return (
<PopoutListAction
icon={Icons.CLAPPER_BOARD}
onClick={props.onClick}
right={<QualityDisplayAction />}
noChevron
>
{t("videoPlayer.buttons.source")}
</PopoutListAction>
);
}

View File

@@ -8,6 +8,8 @@ interface SourceControllerProps {
source: string;
type: MWStreamType;
quality: MWStreamQuality;
providerId?: string;
embedId?: string;
}
export function SourceController(props: SourceControllerProps) {

View File

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

View File

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

View File

@@ -10,11 +10,13 @@ import { AirplayAction } from "@/video/components/actions/AirplayAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { useTranslation } from "react-i18next";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useBannerSize } from "@/hooks/useBanner";
interface VideoPlayerHeaderProps {
media?: MWMediaMeta;
onClick?: () => void;
showControls?: boolean;
isFullScreen?: boolean;
}
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
@@ -25,9 +27,15 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
: false;
const showDivider = props.media && props.onClick;
const { t } = useTranslation();
const bannerHeight = useBannerSize();
return (
<div className="flex items-center">
<div
className="flex items-center"
style={{
paddingTop: props.isFullScreen ? `${bannerHeight}px` : undefined,
}}
>
<div className="flex min-w-0 flex-1 items-center">
<p className="flex items-center truncate">
{props.onClick ? (

View File

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

View File

@@ -1,12 +1,19 @@
import { getCaptionUrl } from "@/backend/helpers/captions";
import {
getCaptionUrl,
convertCustomCaptionFileToWebVTT,
CUSTOM_CAPTION_ID,
} from "@/backend/helpers/captions";
import { MWCaption } from "@/backend/helpers/streams";
import { Icon, Icons } from "@/components/Icon";
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingView } from "@/components/popout/FloatingView";
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { useLoading } from "@/hooks/useLoading";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useMeta } from "@/video/state/logic/meta";
import { useSource } from "@/video/state/logic/source";
import { useMemo, useRef } from "react";
import { ChangeEvent, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
@@ -14,7 +21,10 @@ function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
}
export function CaptionSelectionPopout() {
export function CaptionSelectionPopout(props: {
router: ReturnType<typeof useFloatingRouter>;
prefix: string;
}) {
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
@@ -37,13 +47,53 @@ export function CaptionSelectionPopout() {
);
const currentCaption = source.source?.caption?.id;
const customCaptionUploadElement = useRef<HTMLInputElement>(null);
const [setCustomCaption, loadingCustomCaption, errorCustomCaption] =
useLoading(async (captionFile: File) => {
if (
!captionFile.name.endsWith(".srt") &&
!captionFile.name.endsWith(".vtt")
) {
throw new Error("Only SRT or VTT files are allowed");
}
controls.setCaption(
CUSTOM_CAPTION_ID,
await convertCustomCaptionFileToWebVTT(captionFile)
);
controls.closePopout();
});
async function handleUploadCaption(e: ChangeEvent<HTMLInputElement>) {
if (!e.target.files) {
return;
}
const captionFile = e.target.files[0];
setCustomCaption(captionFile);
}
return (
<>
<PopoutSection className="bg-ash-100 font-bold text-white">
<div>{t("videoPlayer.popouts.captions")}</div>
</PopoutSection>
<div className="relative overflow-y-auto">
<FloatingView
{...props.router.pageProps(props.prefix)}
width={320}
height={500}
>
<FloatingCardView.Header
title={t("videoPlayer.popouts.captions")}
description={t("videoPlayer.popouts.descriptions.captions")}
goBack={() => props.router.navigate("/")}
action={
<button
type="button"
onClick={() =>
props.router.navigate(`${props.prefix}/caption-settings`)
}
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<span>{t("videoPlayer.popouts.captionPreferences.title")}</span>
<Icon icon={Icons.GEAR} />
</button>
}
/>
<FloatingCardView.Content noSection>
<PopoutSection>
<PopoutListEntry
active={!currentCaption}
@@ -54,9 +104,29 @@ export function CaptionSelectionPopout() {
>
{t("videoPlayer.popouts.noCaptions")}
</PopoutListEntry>
<PopoutListEntry
key={CUSTOM_CAPTION_ID}
active={currentCaption === CUSTOM_CAPTION_ID}
loading={loadingCustomCaption}
errored={!!errorCustomCaption}
onClick={() => {
customCaptionUploadElement.current?.click();
}}
>
{currentCaption === CUSTOM_CAPTION_ID
? t("videoPlayer.popouts.customCaption")
: t("videoPlayer.popouts.uploadCustomCaption")}
<input
ref={customCaptionUploadElement}
type="file"
onChange={handleUploadCaption}
className="hidden"
accept=".vtt, .srt"
/>
</PopoutListEntry>
</PopoutSection>
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-200 px-5 py-3 text-sm font-bold uppercase">
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-300 px-5 py-3 text-xs font-bold uppercase">
<Icon className="text-base" icon={Icons.LINK} />
<span>{t("videoPlayer.popouts.linkedCaptions")}</span>
</p>
@@ -79,7 +149,7 @@ export function CaptionSelectionPopout() {
))}
</div>
</PopoutSection>
</div>
</>
</FloatingCardView.Content>
</FloatingView>
);
}

View File

@@ -0,0 +1,150 @@
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 { ChangeEventHandler, useEffect, useRef } from "react";
import { Icon, Icons } from "@/components/Icon";
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">
<label className="font-bold">{props.label}</label>
<input
type="range"
ref={ref}
className="styled-slider slider-progress"
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>
);
}
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,
setCaptionColor,
setCaptionDelay,
setCaptionFontSize,
} = useSettings();
const colors = ["#ffffff", "#00ffff", "#ffff00"];
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")}
max={10}
min={-10}
step={0.1}
valueDisplay={`${captionSettings.delay.toFixed(1)}s`}
value={captionSettings.delay}
onChange={(e) => setCaptionDelay(e.target.valueAsNumber)}
/>
<Slider
label="Size"
min={14}
step={1}
max={60}
value={captionSettings.style.fontSize}
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
/>
<Slider
label={t("videoPlayer.popouts.captionPreferences.opacity")}
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(
`${captionSettings.style.backgroundColor.substring(
0,
7
)}${e.target.valueAsNumber.toString(16)}`
)
}
/>
<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) => (
<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>
))}
</div>
</div>
</FloatingCardView.Content>
</FloatingView>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { useLoading } from "@/hooks/useLoading";
@@ -12,19 +12,22 @@ import { useMeta } from "@/video/state/logic/meta";
import { useControls } from "@/video/state/logic/controls";
import { useWatchedContext } from "@/state/watched";
import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
import { FloatingView } from "@/components/popout/FloatingView";
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { PopoutListEntry } from "./PopoutUtils";
export function EpisodeSelectionPopout() {
const params = useParams<{
media: string;
}>();
const { t } = useTranslation();
const { pageProps, navigate } = useFloatingRouter("/episodes");
const descriptor = useVideoPlayerDescriptor();
const meta = useMeta(descriptor);
const controls = useControls(descriptor);
const [isPickingSeason, setIsPickingSeason] = useState<boolean>(false);
const [currentVisibleSeason, setCurrentVisibleSeason] = useState<{
seasonId: string;
season?: MWSeasonWithEpisodeMeta;
@@ -40,7 +43,6 @@ export function EpisodeSelectionPopout() {
seasonId: sId,
season: undefined,
});
setIsPickingSeason(false);
reqSeasonMeta(decodeJWId(params.media)?.id as string, sId).then((v) => {
if (v?.meta.type !== MWMediaType.SERIES) return;
setCurrentVisibleSeason({
@@ -79,80 +81,59 @@ export function EpisodeSelectionPopout() {
)?.episodes;
}, [meta, currentSeasonId, currentVisibleSeason]);
const toggleIsPickingSeason = () => {
setIsPickingSeason(!isPickingSeason);
};
const setSeason = (id: string) => {
requestSeason(id);
setCurrentVisibleSeason({ seasonId: id });
navigate("/episodes");
};
const { watched } = useWatchedContext();
const titlePositionClass = useMemo(() => {
const offset = isPickingSeason ? "left-0" : "left-10";
return [
"absolute w-full transition-[left,opacity] duration-200",
offset,
].join(" ");
}, [isPickingSeason]);
const closePopout = () => {
controls.closePopout();
};
return (
<>
<PopoutSection className="bg-ash-100 font-bold text-white">
<div className="relative flex items-center">
<button
className={[
"-m-1.5 rounded-lg p-1.5 transition-opacity duration-100 hover:bg-ash-200",
isPickingSeason ? "pointer-events-none opacity-0" : "opacity-1",
].join(" ")}
onClick={toggleIsPickingSeason}
type="button"
>
<Icon icon={Icons.CHEVRON_LEFT} />
</button>
<span
className={[
titlePositionClass,
!isPickingSeason ? "opacity-1" : "opacity-0",
].join(" ")}
>
{currentSeasonInfo?.title || ""}
</span>
<span
className={[
titlePositionClass,
isPickingSeason ? "opacity-1" : "opacity-0",
].join(" ")}
>
{t("videoPlayer.popouts.seasons")}
</span>
</div>
</PopoutSection>
<div className="relative grid h-full grid-rows-[minmax(1px,1fr)]">
<PopoutSection
className={[
"absolute inset-0 z-30 overflow-y-auto border-ash-400 bg-ash-100 transition-[max-height,padding] duration-200",
isPickingSeason
? "max-h-full border-t"
: "max-h-0 overflow-hidden py-0",
].join(" ")}
>
<FloatingView {...pageProps("seasons")} height={600} width={375}>
<FloatingCardView.Header
title={t("videoPlayer.popouts.seasons")}
description={t("videoPlayer.popouts.descriptions.seasons")}
goBack={() => navigate("/episodes")}
backText={`To ${currentSeasonInfo?.title.toLowerCase()}`}
/>
<FloatingCardView.Content>
{currentSeasonInfo
? meta?.seasons?.map?.((season) => (
<PopoutListEntry
key={season.id}
active={meta?.episode?.seasonId === season.id}
onClick={() => setSeason(season.id)}
isOnDarkBackground
>
{season.title}
</PopoutListEntry>
))
: "No season"}
</PopoutSection>
<PopoutSection className="relative h-full overflow-y-auto">
</FloatingCardView.Content>
</FloatingView>
<FloatingView {...pageProps("episodes")} height={600} width={375}>
<FloatingCardView.Header
title={currentSeasonInfo?.title ?? "Unknown season"}
description={t("videoPlayer.popouts.descriptions.episode")}
goBack={closePopout}
close
action={
<button
type="button"
onClick={() => navigate("/episodes/seasons")}
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<span>Other seasons</span>
<Icon icon={Icons.CHEVRON_RIGHT} />
</button>
}
/>
<FloatingCardView.Content>
{loading ? (
<div className="flex h-full w-full items-center justify-center">
<Loading />
@@ -165,7 +146,7 @@ export function EpisodeSelectionPopout() {
className="text-xl text-bink-600"
/>
<p className="mt-6 w-full text-center">
{t("videoPLayer.popouts.errors.loadingWentWrong", {
{t("videoPlayer.popouts.errors.loadingWentWrong", {
seasonTitle: currentSeasonInfo?.title?.toLowerCase(),
})}
</p>
@@ -201,8 +182,8 @@ export function EpisodeSelectionPopout() {
: "No episodes"}
</div>
)}
</PopoutSection>
</div>
</FloatingCardView.Content>
</FloatingView>
</>
);
}

View File

@@ -1,76 +1,35 @@
import { Transition } from "@/components/Transition";
import { useSyncPopouts } from "@/video/components/hooks/useSyncPopouts";
import { EpisodeSelectionPopout } from "@/video/components/popouts/EpisodeSelectionPopout";
import { SourceSelectionPopout } from "@/video/components/popouts/SourceSelectionPopout";
import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelectionPopout";
import { SettingsPopout } from "@/video/components/popouts/SettingsPopout";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useIsMobile } from "@/hooks/useIsMobile";
import {
useInterface,
VideoInterfaceEvent,
} from "@/video/state/logic/interface";
import { useCallback, useEffect, useRef, useState } from "react";
import { useInterface } from "@/video/state/logic/interface";
import { useCallback } from "react";
import { PopoutFloatingCard } from "@/components/popout/FloatingCard";
import { FloatingContainer } from "@/components/popout/FloatingContainer";
import "./Popouts.css";
function ShowPopout(props: { popoutId: string | null }) {
// only updates popout id when a new one is set, so transitions look good
const [popoutId, setPopoutId] = useState<string | null>(props.popoutId);
useEffect(() => {
if (!props.popoutId) return;
setPopoutId(props.popoutId);
}, [props]);
if (popoutId === "episodes") return <EpisodeSelectionPopout />;
if (popoutId === "source") return <SourceSelectionPopout />;
if (popoutId === "captions") return <CaptionSelectionPopout />;
return (
<div className="flex w-full items-center justify-center p-10">
Unknown popout
</div>
);
}
function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) {
const ref = useRef<HTMLDivElement>(null);
const [right, setRight] = useState<number>(0);
const [bottom, setBottom] = useState<number>(0);
const [width, setWidth] = useState<number>(0);
const { isMobile } = useIsMobile(true);
const calculateAndSetCoords = useCallback((rect: DOMRect, w: number) => {
const buttonCenter = rect.left + rect.width / 2;
setBottom(rect ? rect.height + 30 : 30);
setRight(Math.max(window.innerWidth - buttonCenter - w / 2, 30));
}, []);
useEffect(() => {
if (!props.videoInterface.popoutBounds) return;
calculateAndSetCoords(props.videoInterface.popoutBounds, width);
}, [props.videoInterface.popoutBounds, calculateAndSetCoords, width]);
useEffect(() => {
const rect = ref.current?.getBoundingClientRect();
setWidth(rect?.width ?? 0);
}, []);
function ShowPopout(props: { popoutId: string | null; onClose: () => void }) {
const popoutMap = {
settings: <SettingsPopout />,
episodes: <EpisodeSelectionPopout />,
};
return (
<div
ref={ref}
className={[
"absolute z-10 grid w-80 grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200",
isMobile ? "h-[230px]" : " h-[500px]",
].join(" ")}
style={{
right: `${right}px`,
bottom: `${bottom}px`,
}}
<>
{Object.entries(popoutMap).map(([id, el]) => (
<FloatingContainer
key={id}
show={props.popoutId === id}
onClose={props.onClose}
>
<ShowPopout popoutId={props.videoInterface.popout} />
</div>
<PopoutFloatingCard for={id} onClose={props.onClose}>
{el}
</PopoutFloatingCard>
</FloatingContainer>
))}
</>
);
}
@@ -80,20 +39,9 @@ export function PopoutProviderAction() {
const controls = useControls(descriptor);
useSyncPopouts(descriptor);
const handleClick = useCallback(() => {
const onClose = useCallback(() => {
controls.closePopout();
}, [controls]);
return (
<Transition
show={!!videoInterface.popout}
animation="slide-up"
className="h-full"
>
<div className="popout-wrapper pointer-events-auto absolute inset-0">
<div onClick={handleClick} className="absolute inset-0" />
<PopoutContainer videoInterface={videoInterface} />
</div>
</Transition>
);
return <ShowPopout popoutId={videoInterface.popout} onClose={onClose} />;
}

View File

@@ -3,16 +3,32 @@ import { Spinner } from "@/components/layout/Spinner";
import { ProgressRing } from "@/components/layout/ProgressRing";
import { createRef, useEffect, useRef } from "react";
interface PopoutListEntryTypes {
interface PopoutListEntryBaseTypes {
active?: boolean;
children: React.ReactNode;
onClick?: () => void;
isOnDarkBackground?: boolean;
}
interface PopoutListEntryTypes extends PopoutListEntryBaseTypes {
percentageCompleted?: number;
loading?: boolean;
errored?: boolean;
}
interface PopoutListEntryRootTypes extends PopoutListEntryBaseTypes {
right?: React.ReactNode;
noChevron?: boolean;
}
interface PopoutListActionTypes extends PopoutListEntryBaseTypes {
icon?: Icons;
right?: React.ReactNode;
download?: string;
href?: string;
noChevron?: boolean;
}
interface ScrollToActiveProps {
children: React.ReactNode;
className?: string;
@@ -27,6 +43,8 @@ export function ScrollToActive(props: ScrollToActiveProps) {
const ref = createRef<HTMLDivElement>();
const inited = useRef<boolean>(false);
const SAFE_OFFSET = 30;
// Scroll to "active" child on first load (AKA mount except React dumb)
useEffect(() => {
if (inited.current) return;
@@ -45,27 +63,31 @@ export function ScrollToActive(props: ScrollToActiveProps) {
wrapper?.querySelector(".active");
if (wrapper && active) {
let activeYPositionCentered = 0;
const setActiveYPositionCentered = () => {
activeYPositionCentered =
active.getBoundingClientRect().top -
wrapper.getBoundingClientRect().top +
active.offsetHeight / 2;
let wrapperHeight = 0;
let activePos = 0;
let activeHeight = 0;
let wrapperScroll = 0;
const getCoords = () => {
const activeRect = active.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
wrapperHeight = wrapperRect.height;
activeHeight = activeRect.height;
activePos = activeRect.top - wrapperRect.top + wrapper.scrollTop;
wrapperScroll = wrapper.scrollTop;
};
setActiveYPositionCentered();
getCoords();
if (activeYPositionCentered >= wrapper.offsetHeight / 2) {
// Check if the active element is below the vertical center line, then scroll it into center
const isVisible =
activePos + activeHeight <
wrapperScroll + wrapperHeight - SAFE_OFFSET ||
activePos > wrapperScroll + SAFE_OFFSET;
if (isVisible) {
const activeMiddlePos = activePos + activeHeight / 2; // pos of middle of active element
const viewMiddle = wrapperHeight / 2; // half of the available height
const pos = activeMiddlePos - viewMiddle;
wrapper.scrollTo({
top: activeYPositionCentered - wrapper.offsetHeight / 2,
});
}
setActiveYPositionCentered();
if (activeYPositionCentered > wrapper.offsetHeight / 2) {
// If the element is over the vertical center line, scroll to the end
wrapper.scrollTo({
top: wrapper.scrollHeight,
top: pos,
});
}
}
@@ -87,7 +109,7 @@ export function PopoutSection(props: PopoutSectionProps) {
);
}
export function PopoutListEntry(props: PopoutListEntryTypes) {
export function PopoutListEntryBase(props: PopoutListEntryRootTypes) {
const bg = props.isOnDarkBackground ? "bg-ash-200" : "bg-ash-400";
const hover = props.isOnDarkBackground
? "hover:bg-ash-200"
@@ -108,7 +130,28 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
<div className="absolute left-0 h-8 w-0.5 bg-bink-500" />
)}
<span className="truncate">{props.children}</span>
<div className="relative h-4 w-4 min-w-[1rem]">
<div className="relative min-h-[1rem] min-w-[1rem]">
{!props.noChevron && (
<Icon
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100"
icon={Icons.CHEVRON_RIGHT}
/>
)}
{props.right}
</div>
</div>
);
}
export function PopoutListEntry(props: PopoutListEntryTypes) {
return (
<PopoutListEntryBase
isOnDarkBackground={props.isOnDarkBackground}
active={props.active}
onClick={props.onClick}
noChevron={props.loading || props.errored}
right={
<>
{props.errored && (
<Icon
icon={Icons.WARNING}
@@ -118,12 +161,6 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
{props.loading && !props.errored && (
<Spinner className="absolute inset-0 text-base [--color:#9C93B5]" />
)}
{!props.loading && !props.errored && (
<Icon
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100"
icon={Icons.CHEVRON_RIGHT}
/>
)}
{props.percentageCompleted && !props.loading && !props.errored ? (
<ProgressRing
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0"
@@ -135,7 +172,41 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
) : (
""
)}
</div>
</div>
</>
}
>
{props.children}
</PopoutListEntryBase>
);
}
export function PopoutListAction(props: PopoutListActionTypes) {
const entry = (
<PopoutListEntryBase
active={props.active}
isOnDarkBackground={props.isOnDarkBackground}
right={props.right}
onClick={props.href ? undefined : props.onClick}
noChevron={props.noChevron}
>
<div className="flex items-center space-x-3">
{props.icon ? <Icon className="text-xl" icon={props.icon} /> : null}
<div>{props.children}</div>
</div>
</PopoutListEntryBase>
);
return props.href ? (
<a
href={props.href ? props.href : undefined}
rel="noreferrer"
target="_blank"
download={props.download ? props.download : undefined}
onClick={props.onClick}
>
{entry}
</a>
) : (
entry
);
}

View File

@@ -0,0 +1,34 @@
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingDragHandle } from "@/components/popout/FloatingDragHandle";
import { FloatingView } from "@/components/popout/FloatingView";
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { DownloadAction } from "@/video/components/actions/list-entries/DownloadAction";
import { CaptionsSelectionAction } from "@/video/components/actions/list-entries/CaptionsSelectionAction";
import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction";
import { CaptionSelectionPopout } from "./CaptionSelectionPopout";
import { SourceSelectionPopout } from "./SourceSelectionPopout";
import { CaptionSettingsPopout } from "./CaptionSettingsPopout";
export function SettingsPopout() {
const floatingRouter = useFloatingRouter();
const { pageProps, navigate } = floatingRouter;
return (
<>
<FloatingView {...pageProps("/")} width={320}>
<FloatingDragHandle />
<FloatingCardView.Content>
<DownloadAction />
<SourceSelectionAction onClick={() => navigate("/source")} />
<CaptionsSelectionAction onClick={() => navigate("/captions")} />
</FloatingCardView.Content>
</FloatingView>
<SourceSelectionPopout router={floatingRouter} prefix="source" />
<CaptionSelectionPopout router={floatingRouter} prefix="captions" />
<CaptionSettingsPopout
router={floatingRouter}
prefix="caption-settings"
/>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from "react";
import { Icon, Icons } from "@/components/Icon";
import { Icons } from "@/components/Icon";
import { useLoading } from "@/hooks/useLoading";
import { Loading } from "@/components/layout/Loading";
import { IconPatch } from "@/components/buttons/IconPatch";
@@ -15,12 +15,17 @@ import { runEmbedScraper, runProvider } from "@/backend/helpers/run";
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
import { useTranslation } from "react-i18next";
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingView } from "@/components/popout/FloatingView";
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { useSource } from "@/video/state/logic/source";
import { PopoutListEntry } from "./PopoutUtils";
interface EmbedEntryProps {
name: string;
type: MWEmbedType;
url: string;
active: boolean;
onSelect: (stream: MWStream) => void;
}
@@ -40,6 +45,7 @@ export function EmbedEntry(props: EmbedEntryProps) {
isOnDarkBackground
loading={loading}
errored={!!error}
active={props.active}
onClick={() => {
scrapeEmbed();
}}
@@ -49,12 +55,18 @@ export function EmbedEntry(props: EmbedEntryProps) {
);
}
export function SourceSelectionPopout() {
export function SourceSelectionPopout(props: {
router: ReturnType<typeof useFloatingRouter>;
prefix: string;
}) {
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const meta = useMeta(descriptor);
const { source } = useSource(descriptor);
const providerRef = useRef<string | null>(null);
const providers = useMemo(
() =>
meta
@@ -66,7 +78,6 @@ export function SourceSelectionPopout() {
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [scrapeResult, setScrapeResult] =
useState<MWProviderScrapeResult | null>(null);
const showingProvider = !!selectedProvider;
const selectedProviderPopulated = useMemo(
() => providers.find((v) => v.id === selectedProvider) ?? null,
[providers, selectedProvider]
@@ -91,6 +102,8 @@ export function SourceSelectionPopout() {
quality: stream.quality,
source: stream.streamUrl,
type: stream.type,
embedId: stream.embedId,
providerId: providerRef.current ?? undefined,
});
if (meta) {
controls.setMeta({
@@ -101,11 +114,11 @@ export function SourceSelectionPopout() {
controls.closePopout();
}
const providerRef = useRef<string | null>(null);
const selectProvider = (providerId?: string) => {
if (!providerId) {
providerRef.current = null;
setSelectedProvider(null);
props.router.navigate(`/${props.prefix}/source`);
return;
}
@@ -135,16 +148,9 @@ export function SourceSelectionPopout() {
});
providerRef.current = providerId;
setSelectedProvider(providerId);
props.router.navigate(`/${props.prefix}/source/embeds`);
};
const titlePositionClass = useMemo(() => {
const offset = !showingProvider ? "left-0" : "left-10";
return [
"absolute w-full transition-[left,opacity] duration-200",
offset,
].join(" ");
}, [showingProvider]);
const visibleEmbeds = useMemo(() => {
const embeds = scrapeResult?.embeds || [];
@@ -174,45 +180,44 @@ export function SourceSelectionPopout() {
return (
<>
<PopoutSection className="bg-ash-100 font-bold text-white">
<div className="relative flex items-center">
<button
className={[
"-m-1.5 rounded-lg p-1.5 transition-opacity duration-100 hover:bg-ash-200",
!showingProvider ? "pointer-events-none opacity-0" : "opacity-1",
].join(" ")}
onClick={() => selectProvider()}
type="button"
{/* List providers */}
<FloatingView
{...props.router.pageProps(props.prefix)}
width={320}
height={500}
>
<Icon icon={Icons.CHEVRON_LEFT} />
</button>
<span
className={[
titlePositionClass,
showingProvider ? "opacity-1" : "opacity-0",
].join(" ")}
<FloatingCardView.Header
title={t("videoPlayer.popouts.sources")}
description={t("videoPlayer.popouts.descriptions.sources")}
goBack={() => props.router.navigate("/")}
/>
<FloatingCardView.Content>
{providers.map((v) => (
<PopoutListEntry
key={v.id}
active={v.id === source?.providerId}
onClick={() => {
selectProvider(v.id);
}}
>
{selectedProviderPopulated?.displayName ?? ""}
</span>
<span
className={[
titlePositionClass,
!showingProvider ? "opacity-1" : "opacity-0",
].join(" ")}
>
{t("videoPlayer.popouts.sources")}
</span>
</div>
</PopoutSection>
<div className="relative grid h-full grid-rows-[minmax(1px,1fr)]">
<PopoutSection
className={[
"absolute inset-0 z-30 overflow-y-auto border-ash-400 bg-ash-100 transition-[max-height,padding] duration-200",
showingProvider
? "max-h-full border-t"
: "max-h-0 overflow-hidden py-0",
].join(" ")}
{v.displayName}
</PopoutListEntry>
))}
</FloatingCardView.Content>
</FloatingView>
{/* List embeds */}
<FloatingView
{...props.router.pageProps(`embeds`)}
width={320}
height={500}
>
<FloatingCardView.Header
title={selectedProviderPopulated?.displayName ?? ""}
description={t("videoPlayer.popouts.descriptions.embeds")}
goBack={() => props.router.navigate(`/${props.prefix}`)}
/>
<FloatingCardView.Content>
{loading ? (
<div className="flex h-full w-full items-center justify-center">
<Loading />
@@ -237,6 +242,10 @@ export function SourceSelectionPopout() {
onClick={() => {
if (scrapeResult.stream) selectSource(scrapeResult.stream);
}}
active={
selectedProviderPopulated?.id === source?.providerId &&
selectedProviderPopulated?.id === source?.embedId
}
>
Native source
</PopoutListEntry>
@@ -248,6 +257,7 @@ export function SourceSelectionPopout() {
name={v.displayName ?? ""}
key={v.url}
url={v.url}
active={false} // TODO add embed id extractor
onSelect={(stream) => {
selectSource(stream);
}}
@@ -268,22 +278,8 @@ export function SourceSelectionPopout() {
)}
</>
)}
</PopoutSection>
<PopoutSection className="relative h-full overflow-y-auto">
<div>
{providers.map((v) => (
<PopoutListEntry
key={v.id}
onClick={() => {
selectProvider(v.id);
}}
>
{v.displayName}
</PopoutListEntry>
))}
</div>
</PopoutSection>
</div>
</FloatingCardView.Content>
</FloatingView>
</>
);
}

View File

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

View File

@@ -9,6 +9,8 @@ export type VideoSourceEvent = {
quality: MWStreamQuality;
url: string;
type: MWStreamType;
providerId?: string;
embedId?: string;
caption: null | {
id: string;
url: string;

View File

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

View File

@@ -4,6 +4,8 @@ type VideoPlayerSource = {
source: string;
type: MWStreamType;
quality: MWStreamQuality;
providerId?: string;
embedId?: string;
} | null;
export type VideoPlayerStateController = {
@@ -19,6 +21,7 @@ export type VideoPlayerStateController = {
setCaption(id: string, url: string): void;
clearCaption(): void;
getId(): string;
togglePictureInPicture(): void;
};
export type VideoPlayerStateProvider = VideoPlayerStateController & {

View File

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

View File

@@ -58,6 +58,8 @@ export type VideoPlayerState = {
quality: MWStreamQuality;
url: string;
type: MWStreamType;
providerId?: string;
embedId?: string;
caption: null | {
url: string;
id: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ function MediaViewLoading(props: { onGoBack(): void }) {
const { t } = useTranslation();
return (
<div className="relative flex h-screen items-center justify-center">
<div className="relative flex flex-1 items-center justify-center">
<Helmet>
<title>{t("videoPlayer.loading")}</title>
</Helmet>
@@ -62,7 +62,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
}, [stream, props]);
return (
<div className="relative flex h-screen items-center justify-center">
<div className="relative flex flex-1 items-center justify-center">
<Helmet>
<title>{props.meta.meta.title}</title>
</Helmet>
@@ -146,6 +146,8 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
source={props.stream.streamUrl}
type={props.stream.type}
quality={props.stream.quality}
embedId={props.stream.embedId}
providerId={props.stream.providerId}
/>
<ProgressListenerController
startAt={firstStartTime.current}
@@ -181,6 +183,7 @@ export function MediaView() {
return getMetaFromId(data.type, data.id, seasonId);
}
);
// TODO get stream from someplace that actually gets updated
const [stream, setStream] = useState<MWStream | null>(null);
const lastSearchValue = useRef<(string | undefined)[] | null>(null);

View File

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

Some files were not shown because too many files have changed in this diff Show More