Compare commits

...

208 Commits

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

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

View File

@@ -1,13 +1,16 @@
{
"name": "movie-web",
"version": "3.0.4",
"version": "3.0.11",
"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",
@@ -27,7 +30,7 @@
"react-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5",
"react-use": "^17.4.0",
"srt-webvtt": "^2.0.0",
"subsrt-ts": "^2.1.0",
"unpacker": "^1.0.1"
},
"scripts": {
@@ -41,9 +44,8 @@
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
"defaults",
"chrome > 90"
],
"development": [
"last 1 chrome version",
@@ -52,22 +54,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",
@@ -90,6 +97,7 @@
"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

@@ -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",
};

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

View File

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

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

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

View File

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

View File

@@ -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,21 +1,58 @@
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";
// *** TEMPORARY FIX - use other instance
// SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326
const flixHqBase = "https://c.delusionz.xyz/movies/flixhq";
const flixHqBase = "https://api.consumet.org/meta/tmdb";
type FlixHQMediaType = "Movie" | "TV Series";
interface FLIXMediaBase {
id: number;
title: string;
url: string;
image: string;
type: FlixHQMediaType;
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,
};
function flixTypeToMWType(type: FlixHQMediaType) {
if (type === "Movie") return MWMediaType.MOVIE;
return MWMediaType.SERIES;
}
registerProvider({
id: "flixhq",
displayName: "FlixHQ",
rank: 100,
type: [MWMediaType.MOVIE],
async scrape({ media, progress }) {
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
// search for relevant item
const searchResults = await proxiedFetch<any>(
`/${encodeURIComponent(media.meta.title)}`,
@@ -23,46 +60,67 @@ registerProvider({
baseURL: flixHqBase,
}
);
const foundItem = searchResults.results.find((v: any) => {
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
if (v.type !== "Movie" && v.type !== "TV Series") return false;
return (
compareTitle(v.title, media.meta.title) &&
flixTypeToMWType(v.type) === media.meta.type &&
v.releaseDate === media.meta.year
);
});
if (!foundItem) throw new Error("No watchable item found");
const flixId = foundItem.id;
// get media info
progress(25);
const mediaInfo = await proxiedFetch<any>("/info", {
const mediaInfo = await proxiedFetch<any>(`/info/${foundItem.id}`, {
baseURL: flixHqBase,
params: {
id: flixId,
type: flixTypeToMWType(foundItem.type),
},
});
if (!mediaInfo.id) throw new Error("No watchable item found");
// get stream info from media
progress(50);
let episodeId: string | undefined;
if (media.meta.type === MWMediaType.MOVIE) {
episodeId = mediaInfo.episodeId;
} else if (media.meta.type === MWMediaType.SERIES) {
const seasonNo = media.meta.seasonData.number;
const episodeNo = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
}
if (!episodeId) throw new Error("No watchable item found");
progress(75);
const watchInfo = await proxiedFetch<any>(`/watch/${episodeId}`, {
baseURL: flixHqBase,
params: {
id: mediaInfo.id,
},
});
// get stream info from media
progress(75);
const watchInfo = await proxiedFetch<any>("/watch", {
baseURL: flixHqBase,
params: {
episodeId: mediaInfo.episodes[0].id,
mediaId: flixId,
},
});
if (!watchInfo.sources) throw new Error("No watchable item found");
// get best quality source
const source = watchInfo.sources.reduce((p: any, c: any) =>
c.quality > p.quality ? c : p
);
// 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

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

View File

@@ -1,26 +1,33 @@
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",
displayName: "NetFilm",
rank: 15,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
disabled: true, // https://github.com/lamhoang1256/netfilm/issues/25
disabled: true, // The creator has asked us (very nicely) to leave him alone. Until (if) we self-host, netfilm should remain disabled
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
// search for relevant item
const searchResponse = await proxiedFetch<any>(
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
@@ -48,20 +55,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,
},
};
}
@@ -109,20 +125,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,
},
};
},

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,11 @@ export enum Icons {
CASTING = "casting",
CIRCLE_EXCLAMATION = "circle_exclamation",
DOWNLOAD = "download",
GEAR = "gear",
WATCH_PARTY = "watch_party",
PICTURE_IN_PICTURE = "pictureInPicture",
CHECKMARK = "checkmark",
TACHOMETER = "tachometer",
}
export interface IconProps {
@@ -74,11 +79,16 @@ 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>`,
tachometer: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 576 512"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M128 288c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zm154.65-97.08l16.24-48.71c1.16-3.45 3.18-6.35 4.92-9.43-4.73-2.76-9.94-4.78-15.81-4.78-17.67 0-32 14.33-32 32 0 15.78 11.63 28.29 26.65 30.92zM176 176c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm212.27 400H75.73C57.56 397.63 48 359.12 48 320 48 187.66 155.66 80 288 80s240 107.66 240 240c0 39.12-9.56 77.63-27.73 112zM416 320c0 17.67 14.33 32 32 32s32-14.33 32-32-14.33-32-32-32-32 14.33-32 32zm-56.41-182.77c-12.72-4.23-26.16 2.62-30.38 15.17l-45.34 136.01C250.49 290.58 224 318.06 224 352c0 11.72 3.38 22.55 8.88 32h110.25c5.5-9.45 8.88-20.28 8.88-32 0-19.45-8.86-36.66-22.55-48.4l45.34-136.01c4.17-12.57-2.64-26.17-15.21-30.36zM432 208c0-15.8-11.66-28.33-26.72-30.93-.07.21-.07.43-.14.65l-19.5 58.49c4.37 2.24 9.11 3.8 14.36 3.8 17.67-.01 32-14.34 32-32.01z"/></svg>`,
};
function ChromeCastButton() {

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

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

View File

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

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

View File

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

View File

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

View File

@@ -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,40 @@
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,
maxHeight: "70vh",
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,103 @@
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],
...event
}) => {
if (closing.current) return;
const isInScrollable = (event.target as HTMLDivElement).closest(
".overflow-y-auto"
);
if (isInScrollable) return; // Don't attempt to swipe the thing away if it's a scroll area unless the scroll area is at the top and the user is swiping down
const height = cardRect?.height ?? 0;
if (last) {
// if past half height downwards
// 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="is-mobile-view absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
style={{
transform: `translateY(${
window.innerHeight - (cardRect?.height ?? 0) + 200
}px)`,
}}
>
<animated.div
ref={ref}
className={[props.className ?? "", "touch-none"].join(" ")}
style={{
y,
}}
{...bind()}
>
{props.children}
</animated.div>
</div>
);
}

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,
};
}

View File

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

View File

@@ -1,6 +1,8 @@
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";
@@ -21,9 +23,7 @@ if (key) {
}
initializeChromecast();
registerSW({
onNeedRefresh() {
window.location.reload();
},
immediate: true,
});
const LazyLoadedApp = React.lazy(async () => {

View File

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

View File

@@ -1,4 +1,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.4";
export const GA_ID = "G-44YVXRL61C";

View File

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

View File

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

1326
src/setup/iso6391.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -57,29 +57,60 @@
"backToHome": "Back to home",
"backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} left",
"finishAt": "Finish at {{timeFinished}}",
"buttons": {
"episodes": "Episodes",
"source": "Source",
"captions": "Captions",
"download": "Download"
"download": "Download",
"settings": "Settings",
"pictureInPicture": "Picture in Picture",
"playbackSpeed": "Playback speed"
},
"popouts": {
"sources": "Sources",
"seasons": "Seasons",
"captions": "Captions",
"playbackSpeed": "Playback speed",
"customPlaybackSpeed": "Custom playback speed",
"captionPreferences": {
"title": "Customize",
"delay": "Delay",
"fontSize": "Size",
"opacity": "Opacity",
"color": "Color"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "No captions",
"linkedCaptions": "Linked captions",
"customCaption": "Custom caption",
"uploadCustomCaption": "Upload caption",
"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",
"playbackSpeed": "Change the playback speed"
}
},
"errors": {
"fatalError": "The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
}
},
"settings": {
"title": "Settings",
"language":"Language",
"captionLanguage": "Caption Language"
},
"v3": {
"newSiteTitle": "New version now released!",
"newDomain": "https://movie-web.app",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -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,7 +27,11 @@ import { ReactNode, useCallback, useState } from "react";
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
import { DownloadAction } from "@/video/components/actions/DownloadAction";
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
import { SettingsAction } from "./actions/SettingsAction";
import { DividerAction } from "./actions/DividerAction";
import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
type Props = VideoPlayerBaseProps;
@@ -91,6 +92,7 @@ export function VideoPlayer(props: Props) {
<>
<KeyboardShortcutsAction />
<PageTitleAction />
<VolumeAdjustedAction />
<VideoPlayerError onGoBack={props.onGoBack}>
<BackdropAction onBackdropChange={onBackdropChange}>
<CenterPosition>
@@ -143,10 +145,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">
<DownloadAction />
<CaptionsSelectionAction />
<SeriesSelectionAction />
<SourceSelectionAction />
<PictureInPictureAction />
<SettingsAction />
</div>
<FullscreenAction />
</div>
@@ -154,14 +155,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 />
<DownloadAction />
<CaptionsSelectionAction />
<PictureInPictureAction />
<FullscreenAction />
</>
)}
@@ -169,6 +168,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,11 +19,21 @@ 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(() => {
// If NOT a touch, set moved to true
const isTouch = Date.now() - lastTouchEnd.current < 200;
if (!isTouch) setMoved(true);
}, 20);
}
// remove after all
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
if (moved) setMoved(false);
setMoved(false);
timeout.current = null;
}, 3000);
}, [setMoved, moved]);
@@ -32,8 +42,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 +51,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 +69,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,95 @@
import { Transition } from "@/components/Transition";
import { useSettings } from "@/state/settings";
import { sanitize, parseSubtitles } from "@/backend/helpers/captions";
import { ContentCaption } from "subsrt-ts/dist/types/handler";
import { useRef } from "react";
import { useAsync } from "react-use";
import { useVideoPlayerDescriptor } from "../../state/hooks";
import { useProgress } from "../../state/logic/progress";
import { useSource } from "../../state/logic/source";
export function CaptionCue({ text, scale }: { text?: string; scale?: number }) {
const { captionSettings } = useSettings();
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
// added a <br /> for newlines
const html = sanitize(textWithNewlines, {
ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"],
ADD_TAGS: ["v", "lang"],
ALLOWED_ATTR: ["title", "lang"],
});
return (
<p
className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
style={{
...captionSettings.style,
fontSize: captionSettings.style.fontSize * (scale ?? 1),
}}
>
<span
// its sanitised a few lines up
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: html,
}}
dir="auto"
/>
</p>
);
}
export function CaptionRendererAction({
isControlsShown,
}: {
isControlsShown: boolean;
}) {
const descriptor = useVideoPlayerDescriptor();
const source = useSource(descriptor).source;
const videoTime = useProgress(descriptor).time;
const { captionSettings } = useSettings();
const captions = useRef<ContentCaption[]>([]);
useAsync(async () => {
const blobUrl = source?.caption?.url;
if (blobUrl) {
const result = await fetch(blobUrl);
const text = await result.text();
try {
captions.current = parseSubtitles(text);
} catch (error) {
captions.current = [];
}
} else {
captions.current = [];
}
}, [source?.caption?.url]);
if (!captions.current.length) return null;
const isVisible = (start: number, end: number): boolean => {
const delayedStart = start / 1000 + captionSettings.delay;
const delayedEnd = end / 1000 + captionSettings.delay;
return (
Math.max(0, delayedStart) <= videoTime &&
Math.max(0, delayedEnd) >= videoTime
);
};
return (
<Transition
className={[
"pointer-events-none absolute flex w-full flex-col items-center transition-[bottom]",
isControlsShown ? "bottom-24" : "bottom-12",
].join(" ")}
animation="slide-up"
show
>
{captions.current.map(
({ start, end, content }) =>
isVisible(start, end) && (
<CaptionCue key={`${start}-${end}`} text={content} />
)
)}
</Transition>
);
}

View File

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

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

View File

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

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

View File

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

View File

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

View File

@@ -3,39 +3,29 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useSource } from "@/video/state/logic/source";
import { MWStreamType } from "@/backend/helpers/streams";
import { normalizeTitle } from "@/utils/normalizeTitle";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
import { useMeta } from "@/video/state/logic/meta";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
import { PopoutListAction } from "../../popouts/PopoutUtils";
interface Props {
className?: string;
}
export function DownloadAction(props: Props) {
export function DownloadAction() {
const descriptor = useVideoPlayerDescriptor();
const sourceInterface = useSource(descriptor);
const { isMobile } = useIsMobile();
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 (
<a
<PopoutListAction
href={isHLS ? undefined : sourceInterface.source?.url}
rel="noreferrer"
target="_blank"
download={title ? normalizeTitle(title) : undefined}
download={title ? `${normalizeTitle(title)}.mp4` : undefined}
icon={Icons.DOWNLOAD}
>
<VideoPlayerIconButton
className={props.className}
icon={Icons.DOWNLOAD}
disabled={isHLS}
text={isMobile ? (t("videoPlayer.buttons.download") as string) : ""}
/>
</a>
{t("videoPlayer.buttons.download")}
</PopoutListAction>
);
}

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ export const VideoPlayerIconButton = forwardRef<
className={[
"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"
: "",

View File

@@ -1,6 +1,15 @@
import { getCaptionUrl } from "@/backend/helpers/captions";
import { MWCaption } from "@/backend/helpers/streams";
import {
customCaption,
getCaptionUrl,
makeCaptionId,
parseSubtitles,
subtitleTypeList,
} from "@/backend/helpers/captions";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import { Icon, Icons } from "@/components/Icon";
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingView } from "@/components/popout/FloatingView";
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { useLoading } from "@/hooks/useLoading";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
@@ -10,11 +19,10 @@ import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
}
export function CaptionSelectionPopout() {
export function CaptionSelectionPopout(props: {
router: ReturnType<typeof useFloatingRouter>;
prefix: string;
}) {
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
@@ -31,19 +39,44 @@ export function CaptionSelectionPopout() {
async (caption: MWCaption, isLinked: boolean) => {
const id = makeCaptionId(caption, isLinked);
loadingId.current = id;
controls.setCaption(id, await getCaptionUrl(caption));
controls.closePopout();
const blobUrl = await getCaptionUrl(caption);
const result = await fetch(blobUrl);
const text = await result.text();
parseSubtitles(text); // This will throw if the file is invalid
controls.setCaption(id, blobUrl);
// sometimes this doesn't work, so we add a small delay
setTimeout(() => {
controls.closePopout();
}, 100);
}
);
const currentCaption = source.source?.caption?.id;
const customCaptionUploadElement = useRef<HTMLInputElement>(null);
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 +87,35 @@ export function CaptionSelectionPopout() {
>
{t("videoPlayer.popouts.noCaptions")}
</PopoutListEntry>
<PopoutListEntry
key={customCaption}
active={currentCaption === customCaption}
loading={loading && loadingId.current === customCaption}
errored={error && loadingId.current === customCaption}
onClick={() => customCaptionUploadElement.current?.click()}
>
{currentCaption === customCaption
? t("videoPlayer.popouts.customCaption")
: t("videoPlayer.popouts.uploadCustomCaption")}
<input
className="hidden"
ref={customCaptionUploadElement}
accept={subtitleTypeList.join(",")}
type="file"
onChange={(e) => {
if (!e.target.files) return;
const customSubtitle = {
langIso: "custom",
url: URL.createObjectURL(e.target.files[0]),
type: MWCaptionType.UNKNOWN,
};
setCaption(customSubtitle, false);
}}
/>
</PopoutListEntry>
</PopoutSection>
<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 +138,7 @@ export function CaptionSelectionPopout() {
))}
</div>
</PopoutSection>
</div>
</>
</FloatingCardView.Content>
</FloatingView>
);
}

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { useLoading } from "@/hooks/useLoading";
@@ -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

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

View File

@@ -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`,
}}
>
<ShowPopout popoutId={props.videoInterface.popout} />
</div>
<>
{Object.entries(popoutMap).map(([id, el]) => (
<FloatingContainer
key={id}
show={props.popoutId === id}
onClose={props.onClose}
>
<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,34 +130,83 @@ 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]">
{props.errored && (
<Icon
icon={Icons.WARNING}
className="absolute inset-0 text-rose-400"
/>
)}
{props.loading && !props.errored && (
<Spinner className="absolute inset-0 text-base [--color:#9C93B5]" />
)}
{!props.loading && !props.errored && (
<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.percentageCompleted && !props.loading && !props.errored ? (
<ProgressRing
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0"
backingRingClassname="stroke-ash-500"
percentage={
props.percentageCompleted > 90 ? 100 : props.percentageCompleted
}
/>
) : (
""
)}
{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}
className="absolute inset-0 text-rose-400"
/>
)}
{props.loading && !props.errored && (
<Spinner className="absolute inset-0 text-base [--color:#9C93B5]" />
)}
{props.percentageCompleted && !props.loading && !props.errored ? (
<ProgressRing
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0"
backingRingClassname="stroke-ash-500"
percentage={
props.percentageCompleted > 90 ? 100 : props.percentageCompleted
}
/>
) : (
""
)}
</>
}
>
{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

@@ -12,4 +12,4 @@
.popout-wrapper ::-webkit-scrollbar {
/* For some reason the styles don't get applied without the width */
width: 13px;
}
}

View File

@@ -0,0 +1,40 @@
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 { PlaybackSpeedSelectionAction } from "@/video/components/actions/list-entries/PlaybackSpeedSelectionAction";
import { CaptionSelectionPopout } from "./CaptionSelectionPopout";
import { SourceSelectionPopout } from "./SourceSelectionPopout";
import { CaptionSettingsPopout } from "./CaptionSettingsPopout";
import { PlaybackSpeedPopout } from "./PlaybackSpeedPopout";
export function SettingsPopout() {
const floatingRouter = useFloatingRouter();
const { pageProps, navigate } = floatingRouter;
return (
<>
<FloatingView {...pageProps("/")} width={320}>
<FloatingDragHandle />
<FloatingCardView.Content>
<DownloadAction />
<SourceSelectionAction onClick={() => navigate("/source")} />
<CaptionsSelectionAction onClick={() => navigate("/captions")} />
<PlaybackSpeedSelectionAction
onClick={() => navigate("/playback-speed")}
/>
</FloatingCardView.Content>
</FloatingView>
<SourceSelectionPopout router={floatingRouter} prefix="source" />
<CaptionSelectionPopout router={floatingRouter} prefix="captions" />
<CaptionSettingsPopout
router={floatingRouter}
prefix="caption-settings"
/>
<PlaybackSpeedPopout router={floatingRouter} prefix="playback-speed" />
</>
);
}

View File

@@ -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"
>
<Icon icon={Icons.CHEVRON_LEFT} />
</button>
<span
className={[
titlePositionClass,
showingProvider ? "opacity-1" : "opacity-0",
].join(" ")}
>
{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(" ")}
>
{/* List providers */}
<FloatingView
{...props.router.pageProps(props.prefix)}
width={320}
height={500}
>
<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);
}}
>
{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 function resetForSource(s: VideoPlayerState) {
isFirstLoading: true,
hasPlayedOnce: false,
volume: state.mediaPlaying.volume, // volume settings needs to persist through resets
playbackSpeed: 1,
};
state.progress = {
time: 0,
@@ -31,6 +32,9 @@ function initPlayer(): VideoPlayerState {
isFocused: false,
leftControlHovering: false,
popoutBounds: null,
volumeChangedWithKeybind: false,
volumeChangedWithKeybindDebounce: null,
timeFormat: 0,
},
mediaPlaying: {
@@ -42,6 +46,7 @@ function initPlayer(): VideoPlayerState {
isFirstLoading: true,
hasPlayedOnce: false,
volume: 0,
playbackSpeed: 1,
},
progress: {

View File

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

View File

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

View File

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

View File

@@ -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,26 @@ export function createCastingStateProvider(
state.pausedWhenSeeking = state.mediaPlaying.isPaused;
this.pause();
},
togglePictureInPicture() {
// no picture in picture while casting
},
setPlaybackSpeed(num) {
const mediaInfo = new chrome.cast.media.MediaInfo(
state.meta?.meta.meta.id ?? "video",
"video/mp4"
);
(mediaInfo as any).contentUrl = state.source?.url;
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
mediaInfo.metadata = new chrome.cast.media.MovieMediaMetadata();
mediaInfo.metadata.title = state.meta?.meta.meta.title ?? "";
mediaInfo.customData = {
playbackRate: num,
};
const request = new chrome.cast.media.LoadRequest(mediaInfo);
request.autoplay = true;
const session = ins?.getCurrentSession();
session?.loadMedia(request);
},
async setVolume(v) {
// clamp time between 0 and 1
let volume = Math.min(v, 1);
@@ -110,7 +131,7 @@ export function createCastingStateProvider(
movieMeta.title = state.meta?.meta.meta.title ?? "";
const mediaInfo = new chrome.cast.media.MediaInfo(
state.meta?.meta.meta.id ?? "hello",
state.meta?.meta.meta.id ?? "video",
"video/mp4"
);
(mediaInfo as any).contentUrl = source?.source;
@@ -129,12 +150,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 +168,7 @@ export function createCastingStateProvider(
},
clearCaption() {
if (state.source) {
revokeCaptionBlob(state.source.caption?.url);
state.source.caption = null;
updateSource(descriptor, state);
}
@@ -218,6 +243,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 = {
@@ -14,11 +16,13 @@ export type VideoPlayerStateController = {
setSeeking(active: boolean): void;
exitFullscreen(): void;
enterFullscreen(): void;
setVolume(volume: number): void;
setVolume(volume: number, isKeyboardEvent?: boolean): void;
startAirplay(): void;
setCaption(id: string, url: string): void;
clearCaption(): void;
getId(): string;
togglePictureInPicture(): void;
setPlaybackSpeed(num: number): void;
};
export type VideoPlayerStateProvider = VideoPlayerStateController & {

View File

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -146,6 +146,9 @@ 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}
captions={props.stream.captions}
/>
<ProgressListenerController
startAt={firstStartTime.current}
@@ -181,6 +184,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

@@ -9,7 +9,7 @@ import {
import { useWatchedContext } from "@/state/watched";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { EditButton } from "@/components/buttons/EditButton";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useHistory } from "react-router-dom";
import { Modal, ModalCard } from "@/components/layout/Modal";
@@ -22,6 +22,22 @@ function Bookmarks() {
const bookmarks = getFilteredBookmarks();
const [editing, setEditing] = useState(false);
const [gridRef] = useAutoAnimate<HTMLDivElement>();
const { watched } = useWatchedContext();
const bookmarksSorted = useMemo(() => {
return bookmarks
.map((v) => {
return {
...v,
watched: watched.items
.sort((a, b) => b.watchedAt - a.watchedAt)
.find((watchedItem) => watchedItem.item.meta.id === v.id),
};
})
.sort(
(a, b) => (b.watched?.watchedAt || 0) - (a.watched?.watchedAt || 0)
);
}, [watched.items, bookmarks]);
if (bookmarks.length === 0) return null;
@@ -34,7 +50,7 @@ function Bookmarks() {
<EditButton editing={editing} onEdit={setEditing} />
</SectionHeading>
<MediaGrid ref={gridRef}>
{bookmarks.map((v) => (
{bookmarksSorted.map((v) => (
<WatchedMediaCard
key={v.id}
media={v}
@@ -85,15 +101,23 @@ function Watched() {
function NewDomainModal() {
const [show, setShow] = useState(
new URLSearchParams(window.location.search).get("migrated") === "1"
new URLSearchParams(window.location.search).get("migrated") === "1" ||
localStorage.getItem("mw-show-domain-modal") === "true"
);
const [loaded, setLoaded] = useState(false);
const history = useHistory();
const { t } = useTranslation();
const closeModal = useCallback(() => {
localStorage.setItem("mw-show-domain-modal", "false");
setShow(false);
}, []);
useEffect(() => {
const newParams = new URLSearchParams(history.location.search);
newParams.delete("migrated");
if (newParams.get("migrated") === "1")
localStorage.setItem("mw-show-domain-modal", "true");
history.replace({
search: newParams.toString(),
});
@@ -161,7 +185,7 @@ function NewDomainModal() {
<p>{t("v3.tireless")}</p>
</div>
<div className="mt-16 mb-6 flex items-center justify-center">
<Button icon={Icons.PLAY} onClick={() => setShow(false)}>
<Button icon={Icons.PLAY} onClick={() => closeModal()}>
{t("v3.leaveAnnouncement")}
</Button>
</div>

View File

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

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