Compare commits

...

386 Commits
2.1.0 ... 3.0.9

Author SHA1 Message Date
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
mrjvs
3b0232b3d6 Merge pull request #221 from movie-web/dev
Version 3.0.8
2023-03-22 22:51:23 +01:00
mrjvs
f2ea05708f bump version again 🎉 2023-03-22 22:49:18 +01:00
mrjvs
772777835e Merge branch 'master' into dev 2023-03-22 22:47:55 +01:00
mrjvs
dc58c2b55e bump version 2023-03-22 22:44:36 +01:00
mrjvs
c7f3f774bb Merge pull request #218 from movie-web/variety-fixes
Variety of fixes
2023-03-22 22:41:39 +01:00
mrjvs
96656d9a2f fix progress range margins 2023-03-22 22:38:08 +01:00
mrjvs
5419430369 fix pokemon error 2023-03-22 22:31:23 +01:00
mrjvs
731ef6a9aa fix type errors (sort of) 2023-03-19 20:53:44 +01:00
mrjvs
0de9551080 regenerate lock file 2023-03-19 20:29:07 +01:00
mrjvs
0f7c51c198 Merge branch 'dev' into variety-fixes 2023-03-19 20:27:28 +01:00
mrjvs
cf2060bd32 Merge pull request #185 from frost768/feat/subtitle-rendering
Subtitle rendering feature added
2023-03-19 20:25:23 +01:00
mrjvs
ec73d5ef90 fix linting 2023-03-19 20:25:05 +01:00
mrjvs
9c159f01bd remove lint annotations 2023-03-19 20:22:44 +01:00
mrjvs
215b5920c3 fix checkmark styling 2023-03-19 20:20:17 +01:00
mrjvs
6136ff92e6 code cleanup 2023-03-19 20:19:21 +01:00
mrjvs
51dfef18fb cleanup caption cues 2023-03-19 20:10:18 +01:00
mrjvs
12f7f2ee03 fix modal routing 2023-03-19 20:00:56 +01:00
mrjvs
01f46ce23c fine-tune caption rendering 2023-03-19 19:58:30 +01:00
mrjvs
ffe817388a scrollToActive fixed 2023-03-19 19:10:56 +01:00
mrjvs
37d5aaede9 add z-index 0 to video element 2023-03-19 18:36:52 +01:00
mrjvs
e2b1a9bfde fix babel imports and fix package warnings 2023-03-19 18:32:04 +01:00
mrjvs
827d4b576b babel (old browser support) + pwa cache refreshing 2023-03-19 18:01:08 +01:00
frost768
5664540acc last touches to design 2023-03-19 16:17:53 +03:00
frost768
4fe7f1fd1c fs.realpath dependency yarn.lock fix 2023-03-17 20:25:51 +03:00
frost768
12555a5933 remove parent span 2023-03-16 22:10:45 +03:00
frost768
9fe7bdcf47 change sub render positioning to absolute 2023-03-16 21:35:22 +03:00
frost768
20addc039c Merge branch 'feat/subtitle-rendering' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-15 17:54:27 +03:00
frost768
9dad4e687d Merge branch 'dev' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-15 17:54:21 +03:00
Emre Can Minnet
870aa4f105 Merge branch 'movie-web:dev' into feat/subtitle-rendering 2023-03-15 17:54:00 +03:00
frost768
464b78d914 add caption settings popout 2023-03-15 17:48:50 +03:00
frost768
06d043d482 settings view removed 2023-03-15 17:47:50 +03:00
mrjvs
01f98c583a Merge pull request #214 from frost768/dev
fix flixhq episodeId
2023-03-14 23:58:29 +01:00
frost768
f0c9103e0d Merge branch 'dev' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-14 23:54:59 +03:00
frost768
53a0168615 Merge branch 'dev' of https://github.com/movie-web/movie-web into dev 2023-03-14 23:38:06 +03:00
frost768
c9ccf018f2 fix flixhq episodeId 2023-03-14 23:29:39 +03:00
mrjvs
fec1d5ac15 Merge pull request #213 from movie-web/dev
v3.0.7: hotfix for fullscreen popouts
2023-03-14 21:12:54 +01:00
mrjvs
9bedf2b9f1 Merge branch 'master' into dev 2023-03-14 21:11:20 +01:00
mrjvs
57ac2ac677 Merge pull request #212 from movie-web/hotfix-fullscreen-popouts
Hotfix: popouts in body instead of video
2023-03-14 21:10:35 +01:00
mrjvs
60a5f84f2f fix popouts in body instead of video 2023-03-14 21:02:47 +01:00
mrjvs
0d088755ee Merge pull request #207 from movie-web/dev
Version 3.0.6
2023-03-13 21:53:23 +01:00
mrjvs
e5eb09af4d Merge branch 'master' into dev 2023-03-13 21:48:09 +01:00
mrjvs
0036c22970 version bump 2023-03-13 21:42:36 +01:00
mrjvs
8844efa754 Merge pull request #206 from movie-web/feature-small-features
Meta on window + show selected provider and embed
2023-03-13 21:41:20 +01:00
mrjvs
3c68794e5b Merge branch 'dev' into feature-small-features 2023-03-13 21:39:41 +01:00
mrjvs
5fc8355e8e add progress to window
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-03-13 21:27:40 +01:00
frost768
f2efd828dc forgot package.json, damnit 2023-03-13 23:25:42 +03:00
mrjvs
b36324d58e selected providers + meta data on window object + fix dev dependencies
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-03-13 21:25:28 +01:00
frost768
8e79e3acdb yarn.lock 2023-03-13 23:08:55 +03:00
frost768
31cd4d3c75 Merge branch 'dev' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-13 22:45:14 +03:00
frost768
dfe1dd53b7 Merge branch 'dev' of https://github.com/frost768/movie-web into dev 2023-03-13 22:31:20 +03:00
frost768
c2d09566b0 ok vite 2023-03-13 21:50:31 +03:00
mrjvs
f7d51e6d8b Merge pull request #199 from frost768/dev
flixhq scraping improved
2023-03-13 19:41:17 +01:00
Emre Can Minnet
c5ff5817a4 Merge branch 'dev' into dev 2023-03-13 21:39:33 +03:00
frost768
3aa4365a56 'auto' quality removed 2023-03-13 21:37:29 +03:00
mrjvs
80a9f1c91b Merge pull request #200 from JipFr/dev
New popouts & other changes
2023-03-13 19:22:47 +01:00
frost768
f02256f9e0 enum value added 2023-03-13 16:48:28 +03:00
Jip Fr
ed5435f69e YEE 2 2023-03-12 23:23:55 +01:00
Jip Fr
b494469b71 Yee 2023-03-12 23:19:57 +01:00
Jip Fr
bbb9072bc9 Merge remote-tracking branch 'original/dev' into dev 2023-03-12 23:17:03 +01:00
Jip Fr
a34a644d07 Sort bookmarks based on last watch 2023-03-12 22:59:43 +01:00
Jip Fr
506c00960f Fix backdrop tap not working properly on mobile 2023-03-12 22:51:27 +01:00
Jip Fr
93fb343fa9 Don't toggle pause on right mouse click
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-03-12 22:23:14 +01:00
Jip Fr
5e8ad2e996 Localization, center loading, create divider action, rename season/episode route in EpisodeSelectionPopout 2023-03-12 22:19:13 +01:00
Jip Fr
c0867182d7 Add cool new popout stuff
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-03-12 21:49:58 +01:00
mrjvs
89f77debca fix v3 version popup 2023-03-12 19:36:54 +01:00
mrjvs
80f7240f58 fix bad sizing 2023-03-12 19:07:37 +01:00
Jip Fr
a520cf02bb Start new styling for popouts
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-03-12 18:48:46 +01:00
frost768
051c1ba709 flixhq scraping improved 2023-03-12 13:57:01 +03:00
frost768
3bee46ff53 sanitize html before placing into dom 2023-03-11 05:39:06 +03:00
frost768
315c3de3ab Merge branch 'dev' of https://github.com/frost768/movie-web into feat/subtitle-rendering 2023-03-11 01:12:15 +03:00
mrjvs
1c77807987 Merge pull request #196 from movie-web/dev
V3.0.5
2023-03-10 21:21:59 +01:00
mrjvs
9bba47575a Merge branch 'master' into dev 2023-03-10 21:09:18 +01:00
mrjvs
dace2338be bump version 2023-03-10 21:06:03 +01:00
mrjvs
30d8e11992 Merge pull request #189 from lem6ns/external_ids
fix(meta): fallback to no "_latest"
2023-03-10 21:04:27 +01:00
mrjvs
9c9ce92681 Merge branch 'dev' into external_ids 2023-03-10 21:00:56 +01:00
mrjvs
30cc5aa78b fix more linting 2023-03-10 20:59:10 +01:00
mrjvs
ac28f32ef4 fix linting and make code nicer 2023-03-10 20:54:56 +01:00
mrjvs
fca9fea265 Merge pull request #194 from movie-web/feature-frame-protection
Add security headers
2023-03-10 20:45:43 +01:00
James Hawkins
c2bd7714ed Merge branch 'dev' into feature-frame-protection 2023-03-10 19:40:37 +00:00
mrjvs
48214af202 Merge pull request #175 from zisra/dev
Add Picture-in-picture
2023-03-10 20:29:55 +01:00
Emre Can Minnet
007375c1df Merge branch 'dev' into feat/subtitle-rendering 2023-03-10 22:27:30 +03:00
mrjvs
72ad53ee56 add security headers 2023-03-10 20:23:14 +01:00
mrjvs
02d94ba411 Merge branch 'dev' into dev 2023-03-10 19:49:51 +01:00
mrjvs
84913aa63d Merge branch 'dev' into external_ids 2023-03-10 19:48:58 +01:00
mrjvs
9d7ddc03a5 name annotation jobs 2023-03-10 19:41:32 +01:00
mrjvs
5327cbffaa update annotate download script to use v6 2023-03-10 19:38:59 +01:00
mrjvs
695ccef2b5 added yarn cache to deployment script 2023-03-10 19:35:51 +01:00
mrjvs
addd8ca031 fix wrong version 2023-03-10 19:34:25 +01:00
mrjvs
dd662efd72 Merge pull request #192 from movie-web/fix-ci-lineendings
update linting ci
2023-03-10 19:28:26 +01:00
mrjvs
900c70e36a update ci 2023-03-10 19:25:14 +01:00
mrjvs
68a1470447 seperate building and linting 2023-03-10 19:17:11 +01:00
mrjvs
b42d36c5ac fix lint errors 2023-03-10 19:12:22 +01:00
mrjvs
6b9774a210 update linting ci 2023-03-10 19:10:08 +01:00
James Hawkins
a5cd05b144 Merge branch 'dev' into external_ids 2023-03-10 07:09:31 +00:00
James Hawkins
bdb4b3507a Merge pull request #187 from lem6ns/dev
fix(netfilm): use different cdn
2023-03-10 07:08:23 +00:00
cloud
ca6383900a fix(meta): fallback to no "_latest" 2023-03-09 19:22:41 -07:00
cloud
5e97a195d9 fix: vscode settings file 2023-03-09 15:37:06 -07:00
cloud
25e32a14b7 feat(netfilm): add captions 2023-03-09 15:35:39 -07:00
cloud
139a760be0 fix(netfilm): use different cdn 2023-03-09 15:34:54 -07:00
frost768
bd26ed5bc0 fix background color alpha 2023-03-09 21:27:07 +03:00
frost768
ef4cb064e7 add caption settings popout 2023-03-09 20:09:48 +03:00
frost768
875be16c4c add subtitle renderer and remove track element 2023-03-09 20:09:22 +03:00
frost768
f264457c57 add settings context 2023-03-09 20:08:13 +03:00
frost768
7bf1d05f16 add node-webvtt for parsing subtitles 2023-03-09 20:06:34 +03:00
zisra
a3e244285c mrvjs suggested changes 2023-03-04 10:24:56 -06:00
mrjvs
935cb2427b Merge pull request #178 from frost768/dev
feature: subtitle uploading
2023-03-04 15:41:43 +01:00
frost768
404cd897f3 feature: subtitle uploading 2023-03-03 19:33:30 +03:00
Jip Fr
f72d6db253 Floating popout router
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-02-28 23:36:46 +01:00
Jip Fr
b9a9db348b Move episodes over into new popout
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-02-28 21:32:03 +01:00
zisra
fac0a878f3 More fixes 2023-02-28 13:04:01 -06:00
zisra
596e680a18 TypeScript fix 2023-02-28 13:03:06 -06:00
mrjvs
cc51559c29 Floating component start 2023-02-28 19:26:46 +01:00
zisra
c6bf568514 Attempt to fix types 2023-02-28 11:26:30 -06:00
zisra
4a38c77e2d Fix feature detection 2023-02-27 17:44:50 -06:00
zisra
163ca0df29 Fix isPictureInPicture 2023-02-27 17:35:56 -06:00
Jip Fr
19d2b963a8 Add settings popout, add swipe stuff
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-02-27 20:30:06 +01:00
zisra
3fad6edaad Webkit support 2023-02-27 03:43:14 -06:00
zisra
f2f7925cbb CSS changes 2023-02-27 01:19:38 -06:00
zisra
b9026c50f5 Picture in picture 2023-02-27 00:58:47 -06:00
zisra
a1f3986e64 Picture in picture 2023-02-27 00:58:36 -06:00
mrjvs
224cdb6710 Merge pull request #172 from movie-web/dev
version 3.0.4
2023-02-24 23:22:48 +01:00
mrjvs
f76db3e4b7 Merge branch 'master' into dev 2023-02-24 23:18:37 +01:00
mrjvs
9abb009725 bump version 2023-02-24 23:14:27 +01:00
mrjvs
0ca4b3cf49 Merge pull request #171 from movie-web/feature-pwa
PWA
2023-02-24 23:11:01 +01:00
mrjvs
9418a7c45d Merge branch 'dev' into feature-pwa 2023-02-24 23:10:45 +01:00
mrjvs
d34d2c8ce0 review changes 2023-02-24 23:09:27 +01:00
mrjvs
281785a0ef Merge pull request #157 from zisra/dev
Download button
2023-02-24 22:35:46 +01:00
mrjvs
28c008a77f add any purpose 2023-02-24 22:20:35 +01:00
mrjvs
717ebbaeae maskable icon 2023-02-24 22:16:51 +01:00
mrjvs
f715f70f9e fix layout sizings
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-24 22:12:31 +01:00
mrjvs
24aeb68f55 error boundary
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-24 21:45:14 +01:00
zisra
8ed0d3740f Merge branch 'movie-web:dev' into dev 2023-02-24 13:32:47 -06:00
mrjvs
444c751b78 cache busting pwa 2023-02-24 20:12:20 +01:00
mrjvs
63b9adf7d8 disable gdriveplayer 2023-02-24 19:23:26 +01:00
mrjvs
3a1c3ad260 add PWA support 2023-02-24 19:23:00 +01:00
James Hawkins
e68fe0e115 Update netfilm.ts 2023-02-24 14:22:06 +00:00
James Hawkins
d51246120d Update flixhq.ts 2023-02-24 13:24:45 +00:00
James Hawkins
23b439ff79 Temporarily fix flixhq provider
This fix can be used whilst we wait for api.consumet.org to resolve their issues. See https://github.com/consumet/api.consumet.org/issues/326 for more information.
2023-02-24 13:06:05 +00:00
zisra
ac350f276c Merge branch 'movie-web:dev' into dev 2023-02-22 19:27:13 -06:00
mrjvs
854e6bede4 Merge pull request #169 from movie-web/feature-developer-tooling
Development tooling, round robin and better settings
2023-02-22 22:13:16 +01:00
mrjvs
25670814e4 fix tsconfig types 2023-02-22 22:08:11 +01:00
mrjvs
7c2ad68c2a add default for NORMAL_ROUTER setting 2023-02-22 21:54:02 +01:00
mrjvs
e82173efbe update script 2023-02-22 21:49:58 +01:00
mrjvs
485698a43c support for round robin proxies 2023-02-22 21:41:13 +01:00
mrjvs
444156236c add unit tests for providers 2023-02-22 21:15:37 +01:00
mrjvs
4f9ef382dc provider and embed scraper tools 2023-02-22 20:26:19 +01:00
mrjvs
cedc987509 Add developer video testing page 2023-02-22 19:02:23 +01:00
zisra
a99437b4cc Fix title 2023-02-21 15:07:40 -06:00
James Hawkins
7f28e7be3d Merge pull request #167 from movie-web/dev
version 3.0.3
2023-02-21 20:55:50 +00:00
James Hawkins
efc2c8a67d Merge branch 'master' into dev 2023-02-21 20:53:42 +00:00
mrjvs
02cd565f84 version bump 2023-02-21 21:51:57 +01:00
James Hawkins
0625719a4d Merge pull request #166 from movie-web/feature-react-ga
setup GA properly
2023-02-21 20:49:23 +00:00
mrjvs
16298431f4 Merge branch 'dev' into feature-react-ga 2023-02-21 21:49:10 +01:00
James Hawkins
7d6656aef2 Merge pull request #151 from maxwellward/QOL-fixes
Quality of life fixes
2023-02-21 20:48:56 +00:00
James Hawkins
564bcccff8 Merge branch 'dev' into feature-react-ga 2023-02-21 20:48:17 +00:00
James Hawkins
177df9a6f2 Merge branch 'dev' into QOL-fixes 2023-02-21 20:46:31 +00:00
mrjvs
e44b36c83e update tracking 2023-02-21 21:45:14 +01:00
zisra
3696a05e1e Fix suggested changes 2023-02-21 14:17:36 -06:00
mrjvs
abeb68d4a3 Merge pull request #165 from movie-web/dark-reader-fix
Fix darkreader
2023-02-21 19:52:15 +01:00
mrjvs
d10d4faf56 darkreader lock 2023-02-21 19:47:14 +01:00
zisra
f5e5b48616 Update VideoPlayer.tsx 2023-02-20 20:28:09 -06:00
zisra
9ff49e42a3 Update Icon.tsx 2023-02-20 20:26:51 -06:00
zisra
d6a46e1cdc Update Icon.tsx 2023-02-20 20:23:06 -06:00
zisra
d10cbd5e9b Update VideoPlayer.tsx 2023-02-20 20:20:19 -06:00
zisra
1853c8eac7 Create DownloadAction.tsx 2023-02-20 20:18:38 -06:00
Max Ward
6908588c00 Merge branch 'dev' into QOL-fixes 2023-02-20 18:11:38 -08:00
Max Ward
48ab781bb9 Merge branch 'QOL-fixes' of https://github.com/maxwellward/movie-web into QOL-fixes 2023-02-20 18:10:34 -08:00
Max Ward
fbd683e0b5 implement comment fixes 2023-02-20 18:10:22 -08:00
mrjvs
3b3457532a Merge pull request #156 from movie-web/dev
new version
2023-02-20 18:23:08 +01:00
mrjvs
ef7b9ff475 Merge pull request #155 from JipFr/v3-iframe-migration
Bump versions
2023-02-20 18:20:43 +01:00
Jip Fr
c5aacd72ce Bump versions 2023-02-20 18:19:37 +01:00
mrjvs
620e63f17c Merge pull request #154 from JipFr/v3-iframe-migration
V3 iframe migration
2023-02-20 18:18:37 +01:00
Jip Fr
4d8257a05f Remove unused imports 2023-02-20 18:16:48 +01:00
James Hawkins
0f9d7faaf2 Update README.md 2023-02-20 16:52:17 +00:00
Jip Fr
afa89c02a0 Add iframe logic 2023-02-20 17:35:09 +01:00
Max Ward
2bef75dd4a update readme to reflect proper local run command 2023-02-19 22:35:40 -08:00
Max Ward
35adaf3872 add horizontal check to isMobile helper 2023-02-19 22:25:49 -08:00
Max Ward
a2e5e08b20 shrink popouts when on horizontal mobile devices 2023-02-19 21:49:52 -08:00
Max Ward
39ede1b042 improve mobile video player 2023-02-19 21:20:42 -08:00
Max Ward
32288357c4 fix too much darkness fade under search 2023-02-19 18:44:27 -08:00
Max Ward
35ecaece5b make title text fade behind header 2023-02-19 18:42:52 -08:00
Max Ward
25ccd941ca fix some hover states and rounding in edit mode 2023-02-19 18:18:34 -08:00
Max Ward
bfbb4c6b11 reduce space below search on mobile 2023-02-19 17:59:22 -08:00
mrjvs
f13ed7cae1 Merge pull request #148 from movie-web/dev
reorder providers
2023-02-20 00:51:22 +01:00
mrjvs
44f59e9708 Merge branch 'master' into dev 2023-02-20 00:51:07 +01:00
mrjvs
92fa9716e5 reorder providers 2023-02-20 00:50:30 +01:00
mrjvs
e289f9a228 Merge pull request #146 from movie-web/dev
update version number
2023-02-19 23:59:30 +01:00
mrjvs
68868b37a8 update version 2023-02-19 23:58:52 +01:00
mrjvs
b70b58602d Merge pull request #145 from movie-web/dev
deployment fixes
2023-02-19 23:58:18 +01:00
mrjvs
62f8dc0e5e update script 2023-02-19 23:57:45 +01:00
mrjvs
b83258a300 update version 2023-02-19 23:41:38 +01:00
James Hawkins
1d1dbf4bec Merge pull request #144 from movie-web/dev
v3
2023-02-19 22:22:32 +00:00
mrjvs
9267b7bca1 Merge branch 'master' into dev 2023-02-19 23:20:45 +01:00
mrjvs
0f735f49d9 Merge pull request #143 from movie-web/v3
V3
2023-02-19 23:12:43 +01:00
mrjvs
cca38680fe Merge branch 'dev' into v3 2023-02-19 23:08:05 +01:00
mrjvs
a8c84f7343 remove github pages deployment
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
Co-authored-by: William Oldham <wegg7250@gmail.com>
2023-02-19 23:06:10 +01:00
mrjvs
68a186963c add netlify support
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
Co-authored-by: William Oldham <wegg7250@gmail.com>
2023-02-19 23:04:30 +01:00
mrjvs
a2e647297a cleanup todos
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
Co-authored-by: William Oldham <wegg7250@gmail.com>
2023-02-19 23:03:16 +01:00
mrjvs
398644951e more chromecast fixes
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
Co-authored-by: William Oldham <wegg7250@gmail.com>
2023-02-19 22:55:58 +01:00
mrjvs
b886443ea7 bunch of chromecast fixes
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
Co-authored-by: William Oldham <wegg7250@gmail.com>
2023-02-19 22:22:01 +01:00
mrjvs
d6d318006b chromecast button styling
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
Co-authored-by: William Oldham <wegg7250@gmail.com>
2023-02-19 21:00:22 +01:00
mrjvs
0c57aa1a73 finalized domain redirect modal
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-02-19 19:54:34 +01:00
mrjvs
aaf0b56ee7 new domain popup start
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-19 18:36:53 +01:00
mrjvs
b3db58012f linting
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-19 18:03:54 +01:00
mrjvs
c90d59ef93 update script
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-19 16:42:46 +01:00
mrjvs
c441d63074 normal routing instead of hash
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-19 16:05:19 +01:00
mrjvs
a0751380e5 better source selection (empty states, error states, embed support
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-19 15:55:09 +01:00
mrjvs
209fe4369c fix source selector with ids and fixed navigation issue with episode selector
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-19 15:25:58 +01:00
mrjvs
4a35287975 start on jons providers
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: Jonathan Barrow <jonbarrow@users.noreply.github.com>
2023-02-18 22:42:24 +01:00
mrjvs
b43f39b007 domain migrations
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-18 22:41:50 +01:00
mrjvs
4f682d55a9 translations 🎉
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-18 20:01:19 +01:00
mrjvs
ad518a6508 more fuzzy matching for migrations
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-18 14:03:48 +01:00
mrjvs
4d4626806d fuzzy matching for title
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-18 14:00:38 +01:00
mrjvs
18b7619328 Merge pull request #142 from JipFr/v3
Add v3 redirect prompt
2023-02-12 23:46:01 +01:00
Jip Fr
75762aca48 Goodbye year 2023-02-12 23:45:11 +01:00
Jip Fr
eaf5730415 Add v3 prompt thingie 2023-02-12 23:41:48 +01:00
Jelle van Snik
224de76578 more todos 2023-02-12 16:27:27 +01:00
Jelle van Snik
df5f1a5fdb migration error handling 2023-02-12 16:23:55 +01:00
Jelle van Snik
f46263385b Merge branch 'v3' of https://github.com/JamesHawkinss/movie-web into v3 2023-02-12 16:03:40 +01:00
Jelle van Snik
bf3bca9b53 update image links in readme 2023-02-12 16:03:39 +01:00
mrjvs
a93569a201 Merge pull request #141 from JipFr/v3
Migration updates, migrate bookmarks & continue watching
2023-02-12 16:00:20 +01:00
Jelle van Snik
4a0392d1f0 chromecasting humble beginnings
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-02-12 15:58:11 +01:00
Jip Fr
424ec25c5a Add OldBookmarks type to migration wrapper 2023-02-12 14:04:21 +01:00
Jip Fr
bd48d929b9 Migrate bookmarks from v2 > v3 2023-02-12 14:03:50 +01:00
Jelle van Snik
e569f15661 fix restoring of time when changing source 2023-02-12 13:16:18 +01:00
Jelle van Snik
dcc158e705 source reset bug fixes (HLS fix & volume fix) 2023-02-12 13:06:30 +01:00
Jip Fr
942a6cc9c0 Made type-safe versioned store, migrated to it
Co-authored-by: mrjvs <mistrjvs@gmail.com>
2023-02-12 00:41:55 +01:00
Jip Fr
dd14b575eb Move migration out of home into store 2023-02-11 01:05:27 +01:00
Jip Fr
8f23240ea1 Get started on migration 2023-02-11 00:43:38 +01:00
Jelle van Snik
886ffe78ef more todos 2023-02-09 22:13:55 +01:00
Jelle van Snik
772be4b42d add todo 2023-02-09 22:12:38 +01:00
Jelle van Snik
e448c0b5a8 source selection
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-09 22:03:40 +01:00
Jelle van Snik
056f837dcb caption clear + popout math
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-09 20:42:51 +01:00
mrjvs
d89bbaef97 Merge pull request #138 from JipFr/v3
Move PopoutSection scrolling center to ScrollToActive component with improved logic
2023-02-09 12:57:33 +01:00
Jip Fr
0193e8f0c8 Fix scrolling to center if the element is on the low end of the thingy 2023-02-09 12:56:16 +01:00
Jip Fr
6d24e8aa81 Fix PopoutSection scrolling to center 2023-02-09 12:44:11 +01:00
Jelle van Snik
f14606e579 remove old 2023-02-08 22:57:40 +01:00
Jelle van Snik
f97b84516b captions + translation fix
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-02-08 22:51:52 +01:00
Jelle van Snik
c4712044a9 tap backdrop fix, router syncing with popout, start of captions popout,
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-08 21:01:46 +01:00
Jelle van Snik
d9ccce1726 fix drag seeking inteference with real seeking 2023-02-07 23:38:39 +01:00
Jelle van Snik
bd7799b5c1 remove safe insets 2023-02-07 23:35:02 +01:00
Jelle van Snik
d8e2597db7 fix more progress 2023-02-07 23:26:40 +01:00
Jelle van Snik
f8b5c4169c fix more progress recursion 2023-02-07 23:25:46 +01:00
Jelle van Snik
0105c4f6b2 fix popout math and fix seeking not seeking
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: William Oldham <wegg7250@gmail.com>
2023-02-07 23:20:00 +01:00
Jelle van Snik
403142783c add todo 2023-02-07 22:37:46 +01:00
Jelle van Snik
2a3c93c24f episode select popout styling, popout router sync & dragging to update time action 2023-02-07 22:34:20 +01:00
Jelle van Snik
3b4e9ce2ca fix more transitions 2023-02-07 20:03:01 +01:00
Jelle van Snik
6224fb32c4 popout transitions
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-07 19:40:53 +01:00
Jelle van Snik
5e433266ee popout transitions 2023-02-07 18:19:31 +01:00
Jelle van Snik
5d5a727663 add better popout system
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-07 17:49:05 +01:00
Jelle van Snik
76e4bc5851 shortcuts, progress saving fix, error handling, airplay, safe are for full screen only
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-02-07 16:01:05 +01:00
Jelle van Snik
487ba39bbf update todos and remove test view 2023-02-04 19:18:14 +01:00
Jelle van Snik
d213daf91e add more todos 2023-02-04 19:16:06 +01:00
Jelle van Snik
210e60c24d add seeking back to pause action 2023-02-04 19:12:54 +01:00
Jelle van Snik
63be27b9ae episode selector 2023-02-04 19:10:19 +01:00
Jelle van Snik
c3b409631e volume control + progress listener 2023-02-04 18:24:06 +01:00
Jelle van Snik
bb14d63a9c meta data in video player 2023-02-04 16:29:21 +01:00
Jelle van Snik
27ef9be6b1 add back standard video UI 2023-02-04 01:01:54 +01:00
Jelle van Snik
a0c24209bb implement more progres controls 2023-02-03 16:34:41 +01:00
Jelle van Snik
c5a8065db9 add basic functioning player 2023-02-03 15:20:26 +01:00
Jelle van Snik
6ca3196b75 the start detaching video state from react
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-02-02 22:04:58 +01:00
James Hawkins
4d40339602 Merge pull request #134 from edl2/patch-1
fix very important glaring issue
2023-01-29 17:54:06 +00:00
edl2
6e67038ae7 fix very important glaring issue 2023-01-29 12:53:14 -05:00
James Hawkins
73e6f26adb Update README.md 2023-01-27 20:15:30 +00:00
James Hawkins
a1cae1c9f7 fix netfilm qualities 2023-01-26 13:42:08 +00:00
James Hawkins
b1333cfc16 netfilm default season 1 if none exists 2023-01-26 13:35:38 +00:00
James Hawkins
52fef27374 Merge remote-tracking branch 'origin/v3' into v3 2023-01-26 13:31:55 +00:00
James Hawkins
2b81d061f4 Netfilm provider 2023-01-26 13:31:50 +00:00
Jelle van Snik
6edc0d3959 a desperate attempt at chromecasting 2023-01-25 21:44:15 +01:00
Jelle van Snik
8c9d905a91 quality display control, source selection beginning, mobile player UI, keyboard shortcuts
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-01-24 18:12:37 +01:00
Jelle van Snik
701b3db798 volume control touch events fix
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-01-24 15:17:23 +01:00
mrjvs
0ca751f1d2 Merge pull request #127 from destruc7i0n/v3
Show more episode info in the page title for a series
2023-01-24 15:04:25 +01:00
destruc7i0n
c3985873d4 rename to humanizedEpisodeId 2023-01-24 08:38:37 -05:00
destruc7i0n
3604a2f0d7 show the episode info in the page title 2023-01-23 20:39:56 -05:00
mrjvs
6d9a963592 Merge pull request #126 from JipFr/v3
fix(player): use paddedMins for videos less than an hour
2023-01-24 01:35:02 +01:00
Jip Fr
22a2ebac74 fix(player): use paddedMins for videos less than an hour 2023-01-24 01:07:30 +01:00
mrjvs
dfbaac8e93 Merge pull request #125 from JipFr/v3-superstream
style(media): reduce border radius on hover
2023-01-24 00:05:02 +01:00
Jip Fr
da097b97d1 style(media): reduce border radius on hover 2023-01-24 00:02:12 +01:00
Jelle van Snik
6de43d29b9 add navigation todo 2023-01-23 23:58:40 +01:00
Jelle van Snik
177860aed4 series support for continue watching 2023-01-23 23:51:40 +01:00
Jelle van Snik
a077417761 mobile safe video sizes 2023-01-23 23:01:08 +01:00
Jelle van Snik
20685577ab skip buttons, better popout handling
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-01-23 22:38:05 +01:00
Jelle van Snik
b2748f7390 fix a color 2023-01-23 02:01:59 +01:00
Jelle van Snik
b8e49850f4 episode selection
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
2023-01-23 01:55:57 +01:00
Jelle van Snik
1f7e8abda5 remove gomostream 2023-01-22 23:11:43 +01:00
Jelle van Snik
b6ff4bf800 Merge branch 'v3' of https://github.com/JamesHawkinss/movie-web into v3 2023-01-22 23:11:20 +01:00
Jelle van Snik
62220532d7 fix linting 2023-01-22 23:11:18 +01:00
mrjvs
1579e23dba Merge pull request #122 from JipFr/v3-superstream
Slight improvements in superstream scraper
2023-01-22 23:09:10 +01:00
Jip Frijlink
9e8769e4c3 Merge branch 'v3' into v3-superstream 2023-01-22 23:08:39 +01:00
Jip Fr
f339a7156a chore: remove log 2023-01-22 23:07:26 +01:00
Jip Fr
fa9785bf69 chore(superstream): improve import, move quality finding to its own function 2023-01-22 23:06:29 +01:00
Jelle van Snik
ec6e145f82 Merge branch 'v3' of https://github.com/JamesHawkinss/movie-web into v3 2023-01-22 23:04:14 +01:00
Jelle van Snik
5e1727e8f7 provider changes
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-01-22 23:03:55 +01:00
mrjvs
1185383ae4 Merge pull request #121 from JipFr/v3-superstream
feat(providers): add superstream
2023-01-22 23:03:23 +01:00
Jip Fr
7a2865313d feat(providers): add superstream 2023-01-22 23:01:49 +01:00
Jelle van Snik
e7a6484094 fix multi origin and add airplay support 2023-01-22 20:51:58 +01:00
James Hawkins
489f536722 Merge pull request #118 from GiddyGoatGaming/patch-1
Update GitHub actions and node version
2023-01-22 19:08:10 +00:00
Jelle van Snik
f472f04735 episode ids , shorter debounce and flixHQ provider 2023-01-22 19:26:08 +01:00
Jelle van Snik
5a01a68ce4 fix recursive rendering + show meta in player 2023-01-21 23:45:26 +01:00
Jelle van Snik
b6a23aa0b7 update todos 2023-01-19 22:37:16 +01:00
Jelle van Snik
02cc4b7f1d bookmarks, progress and editing of those 2023-01-19 22:29:56 +01:00
Spencer Comfort
9cb182d201 Update linting_testing.yml 2023-01-18 22:56:25 -05:00
Spencer Comfort
5ca384a0f7 Update GitHub actions and node version 2023-01-18 22:55:03 -05:00
Jelle van Snik
fb96026195 continue watching and progress bars 2023-01-17 21:12:39 +01:00
Jelle van Snik
6353bf3799 fix time control once again 2023-01-17 19:11:10 +01:00
Jelle van Snik
40cca10660 update time control display 2023-01-17 13:42:15 +01:00
Jelle van Snik
4d2fc166bc fix time render issue 2023-01-17 01:02:29 +01:00
Jelle van Snik
f37bec7a7a progress restoring logic 2023-01-16 21:53:38 +01:00
Jelle van Snik
f656f80996 more todos 2023-01-16 21:26:01 +01:00
Jelle van Snik
714b378f68 move around some todos 2023-01-16 21:25:16 +01:00
Jelle van Snik
a369682a26 add continue watching and bookmarks back 2023-01-16 21:19:49 +01:00
Jelle van Snik
ca169769bb error handling video player 2023-01-15 16:51:55 +01:00
Jelle van Snik
52b063b10a bunch of todos 2023-01-15 16:01:07 +01:00
Jelle van Snik
8e522e18d4 fix debounce on first render 2023-01-14 16:14:54 +01:00
Jelle van Snik
d161c948cd better progress indicator 2023-01-14 16:03:59 +01:00
Jelle van Snik
2f1058cb9c loading screen usescrape 2023-01-14 01:37:47 +01:00
Jelle van Snik
cf83df64bb add some todos 2023-01-14 00:34:13 +01:00
Jelle van Snik
5967c83d28 add deleted todos back 2023-01-14 00:30:50 +01:00
Jelle van Snik
4d07751a4a first load spinner 2023-01-14 00:27:40 +01:00
Jelle van Snik
a64841507f port providers, media watch page + make search work again
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-01-14 00:12:56 +01:00
Jelle van Snik
6589e095ec cleanup unused code 2023-01-12 22:36:28 +01:00
Jelle van Snik
a9ac3e64db add provider scrape hookiboi 2023-01-12 22:04:28 +01:00
Jelle van Snik
094f9208a8 add quality to streams 2023-01-11 23:41:27 +01:00
Jelle van Snik
e34ddddddb remove old providers 2023-01-11 21:17:44 +01:00
Jelle van Snik
f1257973e7 new backend interfaces 2023-01-11 21:16:48 +01:00
Jelle van Snik
8268abc45d add search backend 2023-01-10 22:43:27 +01:00
Jelle van Snik
46e933dfb7 fix skeleton 2023-01-10 21:23:53 +01:00
Jelle van Snik
d28e6e6735 implement video player on mediapage 2023-01-10 21:18:10 +01:00
Jelle van Snik
35c7ac4b8d lots of UI changes for video player 2023-01-10 19:53:55 +01:00
Jelle van Snik
02ef6c5bf1 add todo 2023-01-10 01:03:51 +01:00
Jelle van Snik
2d9b66d9b8 fullscreen on iphone/ipad 2023-01-10 01:01:51 +01:00
Jelle van Snik
351b35ef98 add top bar and improve ui feel 2023-01-10 00:27:04 +01:00
Jelle van Snik
024325f640 styling of video player controls 2023-01-09 21:51:24 +01:00
Jelle van Snik
098f6af0ae Backdrop + improved seeking 2023-01-08 22:29:38 +01:00
Jelle van Snik
b43b8b19e4 loading + time control
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-01-08 21:18:45 +01:00
Jelle van Snik
44149203cb autoplay and fullscreen feature detection 2023-01-08 20:36:46 +01:00
Jelle van Snik
a9cf056276 add hls todo 2023-01-08 18:01:51 +01:00
Jelle van Snik
09634c6f97 todos 2023-01-08 17:59:25 +01:00
Jelle van Snik
61abce9386 buffering 2023-01-08 17:51:38 +01:00
Jelle van Snik
218a14d5f6 fullscreen video 2023-01-08 16:23:42 +01:00
Jelle van Snik
f93b9b5b0f update linting 2023-01-08 15:42:35 +01:00
Jelle van Snik
196d6ae6e5 new linting config 2023-01-08 15:38:27 +01:00
Jelle van Snik
3a67d50f42 video player starter 2023-01-08 15:37:16 +01:00
Jelle van Snik
eeaa4d7571 custom video player start 2023-01-08 13:15:32 +01:00
Jelle van Snik
b98fdcd94d fix some margins 2023-01-07 23:50:36 +01:00
Jelle van Snik
9fba422673 fix circle figure 2023-01-07 23:48:09 +01:00
Jelle van Snik
e7981539e6 media grids 2023-01-07 23:44:46 +01:00
Jelle van Snik
42402eb5c7 new search view 2023-01-07 21:36:18 +01:00
Jelle van Snik
9d865ca7b4 Merge branch 'dev' 2022-12-29 21:47:24 +01:00
Jelle van Snik
4dd0f22a04 bump version 2022-12-29 21:47:10 +01:00
Jelle van Snik
a9c34d6e35 Merge branch 'dev' 2022-12-29 20:20:28 +01:00
Jelle van Snik
844f5d8b3f bump init script (again) 2022-12-29 20:20:20 +01:00
Jelle van Snik
a2e27b1967 Merge branch 'dev' 2022-12-29 20:16:04 +01:00
Jelle van Snik
06256e311d bump init script 2022-12-29 20:15:56 +01:00
Jelle van Snik
afd2875715 Merge branch 'dev' 2022-12-29 20:10:50 +01:00
Jelle van Snik
9851936c69 latest version init script 2022-12-29 20:10:44 +01:00
James Hawkins
aab58815e0 Merge pull request #116 from movie-web/dev
update 2.1.2
2022-12-29 18:10:37 +00:00
Jelle van Snik
77678063b4 bump version 2022-12-29 19:07:37 +01:00
mrjvs
2f713d3394 Merge pull request #115 from movie-web/feat/improvements
improvements
2022-12-29 19:06:57 +01:00
Jelle van Snik
63cc59d518 add init script 2022-12-29 18:25:57 +01:00
James Hawkins
9a16aff7aa nerd 2022-12-28 11:46:20 +00:00
James Hawkins
36821ff140 Update mw_constants.ts 2022-12-27 20:04:57 +00:00
Jelle van Snik
3b7a95ff62 Merge branch 'dev' of https://github.com/JamesHawkinss/movie-web into dev 2022-12-27 17:43:44 +01:00
Jelle van Snik
1967c47e31 update github action 2022-12-27 17:35:07 +01:00
James Hawkins
98ebc9aec8 add me lol 2022-12-27 16:33:30 +00:00
James Hawkins
80799b7600 Merge pull request #114 from movie-web/feat/config
Better config system
2022-12-27 16:27:29 +00:00
Jelle van Snik
93cb97b304 formatting deploying.yml 2022-12-27 17:26:14 +01:00
Jelle van Snik
131706e2bb bump version 2022-12-27 17:08:01 +01:00
Jelle van Snik
ffcba436d7 update deploy script with new config system 2022-12-27 17:02:12 +01:00
Jelle van Snik
d73ee207da slash comments 2022-12-27 16:57:11 +01:00
Jelle van Snik
c23c1feebc linting fixes 2022-12-27 16:51:32 +01:00
Jelle van Snik
388827b56f new config system 2022-12-27 16:44:36 +01:00
Jelle van Snik
2e8025a241 selfhosting guide 2022-12-27 15:56:28 +01:00
Jelle van Snik
d6edb16ab1 fix linting 2022-12-27 15:12:27 +01:00
Jelle van Snik
4731f350d9 update linting 2022-12-27 15:08:03 +01:00
Jelle van Snik
02e912a760 Fix zip filename 2022-12-27 14:58:54 +01:00
Jelle van Snik
721b8022ab another attempt at zipping 2022-12-27 14:57:07 +01:00
Jelle van Snik
c3e77383ea Add proper zip file to release 2022-12-27 14:52:27 +01:00
245 changed files with 15406 additions and 3806 deletions

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_size = 2
indent_style = space

View File

@@ -1,27 +1,34 @@
const a11yOff = Object.keys(require('eslint-plugin-jsx-a11y').rules) const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
.reduce((acc, rule) => { acc[`jsx-a11y/${rule}`] = 'off'; return acc }, {}) (acc, rule) => {
acc[`jsx-a11y/${rule}`] = "off";
return acc;
},
{}
);
module.exports = { module.exports = {
env: {
browser: true
},
extends: [ extends: [
"airbnb", "airbnb",
"airbnb/hooks", "airbnb/hooks",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"prettier", "prettier",
"plugin:prettier/recommended"
], ],
settings: { ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
"import/resolver": {
typescript: {},
},
},
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
parserOptions: { parserOptions: {
project: "./tsconfig.json", project: "./tsconfig.json",
tsconfigRootDir: "./", tsconfigRootDir: "./"
},
settings: {
"import/resolver": {
typescript: {}
}
}, },
plugins: ["@typescript-eslint", "import"], plugins: ["@typescript-eslint", "import"],
env: {
browser: true,
},
rules: { rules: {
"react/jsx-uses-react": "off", "react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
@@ -36,23 +43,27 @@ module.exports = {
"no-shadow": "off", "no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"], "@typescript-eslint/no-shadow": ["error"],
"no-restricted-syntax": "off", "no-restricted-syntax": "off",
"import/no-unresolved": ["error", { ignore: ["^virtual:"] }],
"react/jsx-props-no-spreading": "off", "react/jsx-props-no-spreading": "off",
"consistent-return": "off", "consistent-return": "off",
"no-continue": "off", "no-continue": "off",
"no-eval": "off", "no-eval": "off",
"no-await-in-loop": "off", "no-await-in-loop": "off",
"no-nested-ternary": "off",
"prefer-destructuring": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"react/jsx-filename-extension": [ "react/jsx-filename-extension": [
"error", "error",
{ extensions: [".js", ".tsx", ".jsx"] }, { extensions: [".js", ".tsx", ".jsx"] }
], ],
"import/extensions": [ "import/extensions": [
"error", "error",
"ignorePackages", "ignorePackages",
{ {
ts: "never", ts: "never",
tsx: "never", tsx: "never"
}, }
], ],
...a11yOff ...a11yOff
}, }
}; };

1
.gitattributes vendored Normal file
View File

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

View File

@@ -12,44 +12,26 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18
cache: 'yarn'
- name: Install Yarn packages - name: Install Yarn packages
run: yarn install run: yarn install
- name: Build project - name: Build project
run: npm run build run: yarn build
- name: Upload production-ready build files - name: Upload production-ready build files
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: production-files name: production-files
path: ./dist path: ./dist
deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifact
uses: actions/download-artifact@v2
with:
name: production-files
path: ./dist
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
cname: movie.squeezebox.dev
release: release:
name: Release name: Release
needs: build needs: build
@@ -57,16 +39,16 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Download artifact - name: Download artifact
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: production-files name: production-files
path: ./dist path: ./dist
- name: Zip files - name: Zip files
run: zip -r ./movie-web.zip ./dist run: cd dist && zip -r ../movie-web.zip .
- name: Get version - name: Get version
id: package-version id: package-version
@@ -91,5 +73,5 @@ jobs:
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./movie-web.zip asset_path: ./movie-web.zip
asset_name: movie-web.js asset_name: movie-web.zip
asset_content_type: application/zip asset_content_type: application/zip

View File

@@ -1,29 +0,0 @@
name: Linting
on:
push:
branches:
- master
- dev
pull_request:
types: [opened, reopened, synchronize]
jobs:
linting:
name: Run linters
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install Node.js
uses: actions/setup-node@v1
with:
node-version: 16
- name: Install Yarn packages
run: yarn install
- name: Run linters
run: yarn lint:strict

49
.github/workflows/linting_testing.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Linting and Testing
on:
push:
branches:
- master
- dev
pull_request:
jobs:
linting:
name: Run Linters
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'yarn'
- name: Install Yarn packages
run: yarn install
- name: Run ESLint
run: yarn lint
building:
name: Build project
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'yarn'
- name: Install Yarn packages
run: yarn install
- name: Build Project
run: yarn build

View File

@@ -1,30 +0,0 @@
name: Testing
on:
push:
branches:
- dev
pull_request:
types: [opened, reopened, synchronize]
permissions: read-all
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install Node.js
uses: actions/setup-node@v1
with:
node-version: 16
- name: Install Yarn packages
run: yarn install
- name: Build project
run: yarn build

6
.gitignore vendored
View File

@@ -9,7 +9,8 @@ node_modules
/coverage /coverage
# production # production
/build /dist
dev-dist
# misc # misc
.DS_Store .DS_Store
@@ -23,3 +24,6 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
package-lock.json package-lock.json
# config
.env

6
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig"
]
}

View File

@@ -1,6 +1,8 @@
{ {
"files.eol": "\n",
"editor.detectIndentation": false,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.tabSize": 2 "editor.defaultFormatter": "dbaeumer.vscode-eslint",
"eslint.format.enable": true,
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
} }

View File

@@ -1,16 +1,16 @@
<h1>movie-web</h1> <h1>movie-web</h1>
<p align="center"> <p align="center">
<a href="https://github.com/JamesHawkinss/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/JamesHawkinss/movie-web/deploying.yml?branch=master&style=flat-square"></a> <a href="https://github.com/movie-web/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/movie-web/movie-web/deploying.yml?branch=master&style=flat-square"></a>
<a href="https://github.com/JamesHawkinss/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/JamesHawkinss/movie-web?style=flat-square"></a> <a href="https://github.com/movie-web/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/movie-web/movie-web?style=flat-square"></a>
<a href="https://github.com/JamesHawkinss/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/JamesHawkinss/movie-web?style=flat-square"></a> <a href="https://github.com/movie-web/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/movie-web/movie-web?style=flat-square"></a>
<a href="https://github.com/JamesHawkinss/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/JamesHawkinss/movie-web?style=flat-square"></a><br/> <a href="https://github.com/movie-web/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/movie-web/movie-web?style=flat-square"></a><br/>
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a> <a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
</p> </p>
movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**. movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
This service works by displaying video files from third-party providers inside an intuitive and aesthic user interface. This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.
Features include: Features include:
@@ -25,26 +25,32 @@ Features include:
- No BS: just a search bar and a video player - No BS: just a search bar and a video player
- No responsibility on the hoster, no databases or api's hosted by us, just a static site - No responsibility on the hoster, no databases or api's hosted by us, just a static site
## Self-hosting / running locally ## Self-hosting
A simple guide has been written to assist in hosting your own instance of movie-web.
Check it out here: [https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md](https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md)
## Running locally for development
To run this project locally for contributing or testing, run the following commands: To run this project locally for contributing or testing, run the following commands:
<h5><b>note: must use yarn to install packages and run NodeJS 16</b></h5> <h5><b>note: must use yarn to install packages and run NodeJS 16</b></h5>
```bash ```bash
git clone https://github.com/JamesHawkinss/movie-web git clone https://github.com/movie-web/movie-web
cd movie-web cd movie-web
yarn install yarn install
yarn start yarn dev
``` ```
To build production files, simply run `yarn build`. To build production files, simply run `yarn build`.
You can also deploy the Cloudflare Worker (in worker.js) and update the proxy URL constant in `/src/mw-constants.ts`. You'll need to deploy a cloudflare service worker as well. Check the [selfhosting guide](https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md) on how to run the service worker. Afterwards you can make a `.env` file and put in the URL. (see `example.env` for an example)
<h2>Contributing - <a href="https://github.com/JamesHawkinss/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/JamesHawkinss/movie-web?style=flat-square"></a> <h2>Contributing - <a href="https://github.com/movie-web/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/movie-web/movie-web?style=flat-square"></a>
<a href="https://github.com/JamesHawkinss/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/JamesHawkinss/movie-web?style=flat-square"></a></h2> <a href="https://github.com/movie-web/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/movie-web/movie-web?style=flat-square"></a></h2>
Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome. Check out [this project's issues](https://github.com/movie-web/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
**All pull requests must be merged into the `dev` branch. it will then be deployed with the next version** **All pull requests must be merged into the `dev` branch. it will then be deployed with the next version**
@@ -52,7 +58,11 @@ Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/iss
This project would not be possible without our amazing contributors and the community. This project would not be possible without our amazing contributors and the community.
<a href="https://github.com/JamesHawkinss/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/JamesHawkinss/movie-web?style=flat-square"></a> <a href="https://github.com/movie-web/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/movie-web/movie-web?style=flat-square"></a>
<div style="display:flex;align-items:center;grid-gap:10px">
<img src="https://github.com/JamesHawkinss.png?size=20" width="20"><span><a href="https://github.com/JamesHawkinss">@JamesHawkinss</a> for original concept.</span>
</div>
<div style="display:flex;align-items:center;grid-gap:10px"> <div style="display:flex;align-items:center;grid-gap:10px">
<img src="https://github.com/JipFr.png?size=20" width="20"><span><a href="https://github.com/JipFr">@JipFr</a> for initial work on <a href="https://github.com/JipFr/movie-cli">movie-cli</a>.</span> <img src="https://github.com/JipFr.png?size=20" width="20"><span><a href="https://github.com/JipFr">@JipFr</a> for initial work on <a href="https://github.com/JipFr/movie-cli">movie-cli</a>.</span>
@@ -62,10 +72,6 @@ This project would not be possible without our amazing contributors and the comm
<img src="https://github.com/mrjvs.png?size=20" width="20"><span><a href="https://github.com/mrjvs">@mrjvs</a> for leading the port to React, and for the beautiful design.</span> <img src="https://github.com/mrjvs.png?size=20" width="20"><span><a href="https://github.com/mrjvs">@mrjvs</a> for leading the port to React, and for the beautiful design.</span>
</div> </div>
<div style="display:flex;align-items:center;grid-gap:10px">
<img src="https://github.com/JoshHeng.png?size=20" width="20"><span><a href="https://github.com/JoshHeng">@JoshHeng</a> for the Cloudflare CORS Proxy and URL routing.</span>
</div>
<div style="display:flex;align-items:center;grid-gap:10px"> <div style="display:flex;align-items:center;grid-gap:10px">
<img src="https://github.com/binaryoverload.png?size=20" width="20"><span><a href="https://github.com/binaryoverload">@binaryoverload</a> for help rewriting the application into React and making the README look ✨ pretty ✨.</span> <img src="https://github.com/binaryoverload.png?size=20" width="20"><span><a href="https://github.com/binaryoverload">@binaryoverload</a> for help rewriting the application into React and making the README look ✨ pretty ✨.</span>
</div> </div>

38
SELFHOSTING.md Normal file
View File

@@ -0,0 +1,38 @@
# Selfhosting tutorial
> **Note:** We do not provide support on how to selfhost, if you cant figure it out then tough luck. Please do not make Github issues or ask in our Discord server for support on how to selfhost.
So you wanna selfhost. This app is made of two parts:
- The proxy
- The client
## Hosting the proxy
The proxy is made as a cloudflare worker, cloudflare has a generous free plan, so you don't need to pay anything unless you get hundreds of users.
1. Create a cloudflare account at [https://dash.cloudflare.com](https://dash.cloudflare.com)
2. Navigate to `Workers`.
3. If it asks you, choose a subdomain
4. If it asks for a workers plan, press "Continue with free"
5. Create a new service with a name of your choice. Must be type `HTTP handler`
6. On the service page, Click `Quick edit`
7. Download the `worker.js` file from the latest release of the proxy: [https://github.com/movie-web/simple-proxy/releases/latest](https://github.com/movie-web/simple-proxy/releases/latest)
8. Open the downloaded `worker.js` file in notepad, VScode or similar.
9. Copy the text contents of the `worker.js` file.
10. Paste the text contents into the edit screen of the cloudflare service worker.
11. Click `Save and deploy` and confirm.
Your proxy is now hosted on cloudflare. Note the url of your worker. you will need it later.
## Hosting the client
1. Download the file `movie-web.zip` from the latest release: [https://github.com/movie-web/movie-web/releases/latest](https://github.com/movie-web/movie-web/releases/latest)
2. Extract the zip file so you can edit the files.
3. Open `config.js` in notepad, VScode or similar.
4. Put your cloudflare proxy URL inbetween the double qoutes of `VITE_CORS_PROXY_URL: "",`. Make sure to not have a slash at the end of your URL.
Example (THIS IS MINE, IT WONT WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev",`
5. Save the file
Your client has been prepared, you can now host it on any webhost.
It doesn't require php, its just a standard static page.

6
example.env Normal file
View File

@@ -0,0 +1,6 @@
# make sure the cors proxy url does NOT have a slash at the end
VITE_CORS_PROXY_URL=...
# the keys below are optional - defaults are provided
VITE_TMDB_API_KEY=...
VITE_OMDB_API_KEY=...

View File

@@ -1,44 +1,34 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-44YVXRL61C"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-44YVXRL61C");
</script>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta <meta
name="description" name="description"
content="Because watching movies legally is boring" content="The place for your favourite movies & shows"
/> />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#E880C5" /> <meta name="msapplication-TileColor" content="#120f1d" />
<meta name="msapplication-TileColor" content="#E880C5" /> <meta name="theme-color" content="#120f1d" />
<meta name="theme-color" content="#E880C5" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap" href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<script src="config.js"></script>
<script src="https://cdn.jsdelivr.net/gh/movie-web/6C6F6C7A@8b821f445b83d51ef1b8f42c99b7346f6b47dce5/out.js"></script>
<!-- prevent darkreader extension from messing with our already dark site -->
<meta name="darkreader-lock" />
<title>movie-web</title> <title>movie-web</title>
</head> </head>
<body> <body>

View File

@@ -1,37 +1,52 @@
{ {
"name": "movie-web", "name": "movie-web",
"version": "2.1.0", "version": "3.0.9",
"private": true, "private": true,
"homepage": "https://movie.squeezebox.dev", "homepage": "https://movie.squeezebox.dev",
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^1.0.0-beta.5",
"@headlessui/react": "^1.5.0", "@headlessui/react": "^1.5.0",
"@react-spring/web": "^9.7.1",
"@use-gesture/react": "^10.2.24",
"core-js": "^3.29.1",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"dompurify": "^3.0.1",
"fscreen": "^1.2.0",
"fuse.js": "^6.4.6", "fuse.js": "^6.4.6",
"hls.js": "^1.0.7", "hls.js": "^1.0.7",
"i18next": "^22.4.5", "i18next": "^22.4.5",
"i18next-browser-languagedetector": "^7.0.1", "i18next-browser-languagedetector": "^7.0.1",
"i18next-http-backend": "^2.1.0",
"json5": "^2.2.0", "json5": "^2.2.0",
"lodash.throttle": "^4.1.1",
"nanoid": "^4.0.0", "nanoid": "^4.0.0",
"node-webvtt": "^1.9.4",
"ofetch": "^1.0.0",
"pako": "^2.1.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-ga4": "^2.0.0",
"react-helmet": "^6.1.0",
"react-i18next": "^12.1.1", "react-i18next": "^12.1.1",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5",
"react-use": "^17.4.0",
"srt-webvtt": "^2.0.0", "srt-webvtt": "^2.0.0",
"unpacker": "^1.0.1" "unpacker": "^1.0.1"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"test": "vitest run",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint --ext .tsx,.ts src", "lint": "eslint --ext .tsx,.ts src",
"lint:strict": "eslint --ext .tsx,.ts --max-warnings 0 src" "lint:fix": "eslint --fix --ext .tsx,.ts src",
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", "defaults",
"not dead", "chrome > 90"
"not op_mini all"
], ],
"development": [ "development": [
"last 1 chrome version", "last 1 chrome version",
@@ -40,30 +55,50 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.21.0",
"@tailwindcss/line-clamp": "^0.4.2",
"@types/chromecast-caf-sender": "^1.0.5",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/dompurify": "^2.4.0",
"@types/fscreen": "^1.0.1",
"@types/lodash.throttle": "^4.1.7",
"@types/node": "^17.0.15", "@types/node": "^17.0.15",
"@types/pako": "^2.0.0",
"@types/react": "^17.0.39", "@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",
"@types/react-router": "^5.1.18", "@types/react-helmet": "^6.1.6",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-stickynode": "^4.0.0",
"@types/react-transition-group": "^4.4.5",
"@typescript-eslint/eslint-plugin": "^5.13.0", "@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0", "@typescript-eslint/parser": "^5.13.0",
"@vitejs/plugin-react-swc": "^3.0.0", "@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"eslint": "^8.10.0", "eslint": "^8.10.0",
"eslint-config-airbnb": "19.0.4", "eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.6.0",
"eslint-import-resolver-typescript": "^2.5.0", "eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "7.29.4", "eslint-plugin-react": "7.29.4",
"eslint-plugin-react-hooks": "4.3.0", "eslint-plugin-react-hooks": "4.3.0",
"jsdom": "^21.1.0",
"postcss": "^8.4.20", "postcss": "^8.4.20",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.7", "prettier-plugin-tailwindcss": "^0.1.7",
"tailwind-scrollbar": "^2.0.1", "tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4",
"typescript": "^4.6.4", "typescript": "^4.6.4",
"vite": "^4.0.1" "vite": "^4.0.1",
"vite-plugin-checker": "^0.5.6",
"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"
} }
} }

4
prettierrc.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
trailingComma: "all",
singleQuote: true
};

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

1
public/_redirects Normal file
View File

@@ -0,0 +1 @@
/* /index.html 200

View File

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

6
public/config.js Normal file
View File

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

@@ -1,50 +0,0 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading": "Fetching your favourite shows...",
"providersFailed": "{{fails}}/{{total}} providers failed!",
"allResults": "That's all we have!",
"noResults": "We couldn't find anything!",
"allFailed": "All providers have failed!",
"headingTitle": "Search results",
"headingLink": "Back to home",
"bookmarks": "Bookmarks",
"continueWatching": "Continue Watching",
"tagline": "Because watching legally is boring",
"title": "What do you want to watch?",
"placeholder": "What do you want to watch?"
},
"media": {
"invalidUrl": "Your URL may be invalid",
"arrowText": "Go back"
},
"seasons": {
"season": "Season {{season}}",
"failed": "Failed to get season data"
},
"notFound": {
"backArrow": "Back to home",
"media": {
"title": "Couldn't find that media",
"description": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL"
},
"provider": {
"title": "This provider has been disabled",
"description": "We had issues with the provider or it was too unstable to use, so we had to disable it."
},
"page": {
"title": "Couldn't find that page",
"description": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for."
}
},
"searchBar": {
"movie": "Movie",
"series": "Series",
"Search": "Search"
},
"errorBoundary": {
"text": "The app encountered an error and wasn't able to recover, please report it to the"
}
}

1
public/ping.txt Normal file
View File

@@ -0,0 +1 @@
pong

View File

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

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

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

View File

@@ -1,28 +0,0 @@
import { Redirect, Route, Switch } from "react-router-dom";
import { MWMediaType } from "@/providers";
import { BookmarkContextProvider } from "@/state/bookmark";
import { WatchedContextProvider } from "@/state/watched";
import { NotFoundPage } from "@/views/notfound/NotFoundView";
import "./index.css";
import { MediaView } from "./views/MediaView";
import { SearchView } from "./views/SearchView";
function App() {
return (
<WatchedContextProvider>
<BookmarkContextProvider>
<Switch>
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
<Route exact path="/media/movie/:media" component={MediaView} />
<Route exact path="/media/series/:media" component={MediaView} />
<Route exact path="/search/:type/:query?" component={SearchView} />
<Route path="*" component={NotFoundPage} />
</Switch>
</BookmarkContextProvider>
</WatchedContextProvider>
);
}
export default App;

View File

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

View File

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

View File

@@ -0,0 +1 @@
embed scrapers go here

View File

@@ -0,0 +1,20 @@
import { MWEmbedType } from "@/backend/helpers/embed";
import { registerEmbedScraper } from "@/backend/helpers/register";
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
registerEmbedScraper({
id: "playm4u",
displayName: "playm4u",
for: MWEmbedType.PLAYM4U,
rank: 0,
async getStream() {
// throw new Error("Oh well 2")
return {
embedId: "",
streamUrl: "",
quality: MWStreamQuality.Q1080P,
captions: [],
type: MWStreamType.MP4,
};
},
});

View File

@@ -0,0 +1,66 @@
import { MWEmbedType } from "@/backend/helpers/embed";
import { registerEmbedScraper } from "@/backend/helpers/register";
import {
MWStreamQuality,
MWStreamType,
MWEmbedStream,
} from "@/backend/helpers/streams";
import { proxiedFetch } from "@/backend/helpers/fetch";
const HOST = "streamm4u.club";
const URL_BASE = `https://${HOST}`;
const URL_API = `${URL_BASE}/api`;
const URL_API_SOURCE = `${URL_API}/source`;
async function scrape(embed: string) {
const sources: MWEmbedStream[] = [];
const embedID = embed.split("/").pop();
console.log(`${URL_API_SOURCE}/${embedID}`);
const json = await proxiedFetch<any>(`${URL_API_SOURCE}/${embedID}`, {
method: "POST",
body: `r=&d=${HOST}`,
});
if (json.success) {
const streams = json.data;
for (const stream of streams) {
sources.push({
embedId: "",
streamUrl: stream.file as string,
quality: stream.label as MWStreamQuality,
type: stream.type as MWStreamType,
captions: [],
});
}
}
return sources;
}
// TODO check out 403 / 404 on successfully returned video stream URLs
registerEmbedScraper({
id: "streamm4u",
displayName: "streamm4u",
for: MWEmbedType.STREAMM4U,
rank: 100,
async getStream({ progress, url }) {
// const scrapingThreads = [];
// const streams = [];
const sources = (await scrape(url)).sort(
(a, b) =>
Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", ""))
);
// const preferredSourceIndex = 0;
const preferredSource = sources[0];
if (!preferredSource) throw new Error("No source found");
progress(100);
return preferredSource;
},
});

View File

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

View File

@@ -0,0 +1,27 @@
import { MWEmbedStream } from "./streams";
export enum MWEmbedType {
M4UFREE = "m4ufree",
STREAMM4U = "streamm4u",
PLAYM4U = "playm4u",
}
export type MWEmbed = {
type: MWEmbedType;
url: string;
};
export type MWEmbedContext = {
progress(percentage: number): void;
url: string;
};
export type MWEmbedScraper = {
id: string;
displayName: string;
for: MWEmbedType;
rank: number;
disabled?: boolean;
getStream(ctx: MWEmbedContext): Promise<MWEmbedStream>;
};

View File

@@ -0,0 +1,60 @@
import { conf } from "@/setup/config";
import { ofetch } from "ofetch";
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
// round robins all proxy urls
function getProxyUrl(): string {
const url = conf().PROXY_URLS[proxyUrlIndex];
proxyUrlIndex = (proxyUrlIndex + 1) % conf().PROXY_URLS.length;
return url;
}
type P<T> = Parameters<typeof ofetch<T>>;
type R<T> = ReturnType<typeof ofetch<T>>;
const baseFetch = ofetch.create({
retry: 0,
});
export function makeUrl(url: string, data: Record<string, string>) {
let parsedUrl: string = url;
Object.entries(data).forEach(([k, v]) => {
parsedUrl = parsedUrl.replace(`{${k}}`, encodeURIComponent(v));
});
return parsedUrl;
}
export function mwFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
return baseFetch<T>(url, ops);
}
export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
let combinedUrl = ops?.baseURL ?? "";
if (
combinedUrl.length > 0 &&
combinedUrl.endsWith("/") &&
url.startsWith("/")
)
combinedUrl += url.slice(1);
else if (
combinedUrl.length > 0 &&
!combinedUrl.endsWith("/") &&
!url.startsWith("/")
)
combinedUrl += `/${url}`;
else combinedUrl += url;
const parsedUrl = new URL(combinedUrl);
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
parsedUrl.searchParams.set(k, v);
});
return baseFetch<T>(getProxyUrl(), {
...ops,
baseURL: undefined,
params: {
destination: parsedUrl.toString(),
},
});
}

View File

@@ -0,0 +1,36 @@
import { DetailedMeta } from "../metadata/getmeta";
import { MWMediaType } from "../metadata/types";
import { MWEmbed } from "./embed";
import { MWStream } from "./streams";
export type MWProviderScrapeResult = {
stream?: MWStream;
embeds: MWEmbed[];
};
type MWProviderBase = {
progress(percentage: number): void;
media: DetailedMeta;
};
type MWProviderTypeSpecific =
| {
type: MWMediaType.MOVIE | MWMediaType.ANIME;
episode?: undefined;
season?: undefined;
}
| {
type: MWMediaType.SERIES;
episode: string;
season: string;
};
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;
export type MWProvider = {
id: string;
displayName: string;
rank: number;
disabled?: boolean;
type: MWMediaType[];
scrape(ctx: MWProviderContext): Promise<MWProviderScrapeResult>;
};

View File

@@ -0,0 +1,72 @@
import { MWEmbedScraper, MWEmbedType } from "./embed";
import { MWProvider } from "./provider";
let providers: MWProvider[] = [];
let embeds: MWEmbedScraper[] = [];
export function registerProvider(provider: MWProvider) {
if (provider.disabled) return;
providers.push(provider);
}
export function registerEmbedScraper(embed: MWEmbedScraper) {
if (embed.disabled) return;
embeds.push(embed);
}
export function initializeScraperStore() {
// sort by ranking
providers = providers.sort((a, b) => b.rank - a.rank);
embeds = embeds.sort((a, b) => b.rank - a.rank);
// check for invalid ranks
let lastRank: null | number = null;
providers.forEach((v) => {
if (lastRank === null) {
lastRank = v.rank;
return;
}
if (lastRank === v.rank)
throw new Error(`Duplicate rank number for provider ${v.id}`);
lastRank = v.rank;
});
lastRank = null;
providers.forEach((v) => {
if (lastRank === null) {
lastRank = v.rank;
return;
}
if (lastRank === v.rank)
throw new Error(`Duplicate rank number for embed scraper ${v.id}`);
lastRank = v.rank;
});
// check for duplicate ids
const providerIds = providers.map((v) => v.id);
if (
providerIds.length > 0 &&
new Set(providerIds).size !== providerIds.length
)
throw new Error("Duplicate IDS in providers");
const embedIds = embeds.map((v) => v.id);
if (embedIds.length > 0 && new Set(embedIds).size !== embedIds.length)
throw new Error("Duplicate IDS in embed scrapers");
// check for duplicate embed types
const embedTypes = embeds.map((v) => v.for);
if (embedTypes.length > 0 && new Set(embedTypes).size !== embedTypes.length)
throw new Error("Duplicate types in embed scrapers");
}
export function getProviders(): MWProvider[] {
return providers;
}
export function getEmbeds(): MWEmbedScraper[] {
return embeds;
}
export function getEmbedScraperByType(
type: MWEmbedType
): MWEmbedScraper | null {
return getEmbeds().find((v) => v.for === type) ?? null;
}

View File

@@ -0,0 +1,52 @@
import { MWEmbed, MWEmbedContext, MWEmbedScraper } from "./embed";
import {
MWProvider,
MWProviderContext,
MWProviderScrapeResult,
} from "./provider";
import { getEmbedScraperByType } from "./register";
import { MWStream } from "./streams";
function sortProviderResult(
ctx: MWProviderScrapeResult
): MWProviderScrapeResult {
ctx.embeds = ctx.embeds
.map<[MWEmbed, MWEmbedScraper | null]>((v) => [
v,
v.type ? getEmbedScraperByType(v.type) : null,
])
.sort(([, a], [, b]) => (b?.rank ?? 0) - (a?.rank ?? 0))
.map((v) => v[0]);
return ctx;
}
export async function runProvider(
provider: MWProvider,
ctx: MWProviderContext
): Promise<MWProviderScrapeResult> {
try {
const data = await provider.scrape(ctx);
return sortProviderResult(data);
} catch (err) {
console.error("Failed to run provider", err, {
id: provider.id,
ctx: { ...ctx },
});
throw err;
}
}
export async function runEmbedScraper(
scraper: MWEmbedScraper,
ctx: MWEmbedContext
): Promise<MWStream> {
try {
return await scraper.getStream(ctx);
} catch (err) {
console.error("Failed to run embed scraper", {
id: scraper.id,
ctx: { ...ctx },
});
throw err;
}
}

View File

@@ -0,0 +1,173 @@
import { MWProviderContext, MWProviderScrapeResult } from "./provider";
import { getEmbedScraperByType, getProviders } from "./register";
import { runEmbedScraper, runProvider } from "./run";
import { MWStream } from "./streams";
import { DetailedMeta } from "../metadata/getmeta";
import { MWMediaType } from "../metadata/types";
interface MWProgressData {
type: "embed" | "provider";
id: string;
eventId: string;
percentage: number;
errored: boolean;
}
interface MWNextData {
id: string;
eventId: string;
type: "embed" | "provider";
}
type MWProviderRunContextBase = {
media: DetailedMeta;
onProgress?: (data: MWProgressData) => void;
onNext?: (data: MWNextData) => void;
};
type MWProviderRunContextTypeSpecific =
| {
type: MWMediaType.MOVIE | MWMediaType.ANIME;
episode: undefined;
season: undefined;
}
| {
type: MWMediaType.SERIES;
episode: string;
season: string;
};
export type MWProviderRunContext = MWProviderRunContextBase &
MWProviderRunContextTypeSpecific;
async function findBestEmbedStream(
result: MWProviderScrapeResult,
providerId: string,
ctx: MWProviderRunContext
): Promise<MWStream | null> {
if (result.stream) {
return {
...result.stream,
providerId,
embedId: providerId,
};
}
let embedNum = 0;
for (const embed of result.embeds) {
embedNum += 1;
if (!embed.type) continue;
const scraper = getEmbedScraperByType(embed.type);
if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`);
const eventId = [providerId, scraper.id, embedNum].join("|");
ctx.onNext?.({ id: scraper.id, type: "embed", eventId });
let stream: MWStream;
try {
stream = await runEmbedScraper(scraper, {
url: embed.url,
progress(num) {
ctx.onProgress?.({
errored: false,
eventId,
id: scraper.id,
percentage: num,
type: "embed",
});
},
});
} catch {
ctx.onProgress?.({
errored: true,
eventId,
id: scraper.id,
percentage: 100,
type: "embed",
});
continue;
}
ctx.onProgress?.({
errored: false,
eventId,
id: scraper.id,
percentage: 100,
type: "embed",
});
stream.providerId = providerId;
return stream;
}
return null;
}
export async function findBestStream(
ctx: MWProviderRunContext
): Promise<MWStream | null> {
const providers = getProviders();
for (const provider of providers) {
const eventId = provider.id;
ctx.onNext?.({ id: provider.id, type: "provider", eventId });
let result: MWProviderScrapeResult;
try {
let context: MWProviderContext;
if (ctx.type === MWMediaType.SERIES) {
context = {
media: ctx.media,
type: ctx.type,
episode: ctx.episode,
season: ctx.season,
progress(num) {
ctx.onProgress?.({
percentage: num,
eventId,
errored: false,
id: provider.id,
type: "provider",
});
},
};
} else {
context = {
media: ctx.media,
type: ctx.type,
progress(num) {
ctx.onProgress?.({
percentage: num,
eventId,
errored: false,
id: provider.id,
type: "provider",
});
},
};
}
result = await runProvider(provider, context);
} catch (err) {
ctx.onProgress?.({
percentage: 100,
errored: true,
eventId,
id: provider.id,
type: "provider",
});
continue;
}
ctx.onProgress?.({
errored: false,
id: provider.id,
eventId,
percentage: 100,
type: "provider",
});
const stream = await findBestEmbedStream(result, provider.id, ctx);
if (!stream) continue;
return stream;
}
return null;
}

View File

@@ -0,0 +1,38 @@
export enum MWStreamType {
MP4 = "mp4",
HLS = "hls",
}
export enum MWCaptionType {
VTT = "vtt",
SRT = "srt",
}
export enum MWStreamQuality {
Q360P = "360p",
Q540P = "540p",
Q480P = "480p",
Q720P = "720p",
Q1080P = "1080p",
QUNKNOWN = "unknown",
}
export type MWCaption = {
needsProxy?: boolean;
url: string;
type: MWCaptionType;
langIso: string;
};
export type MWStream = {
streamUrl: string;
type: MWStreamType;
quality: MWStreamQuality;
providerId?: string;
embedId?: string;
captions: MWCaption[];
};
export type MWEmbedStream = MWStream & {
embedId: string;
};

14
src/backend/index.ts Normal file
View File

@@ -0,0 +1,14 @@
import { initializeScraperStore } from "./helpers/register";
// providers
import "./providers/gdriveplayer";
import "./providers/flixhq";
import "./providers/superstream";
import "./providers/netfilm";
import "./providers/m4ufree";
// embeds
import "./embeds/streamm4u";
import "./embeds/playm4u";
initializeScraperStore();

View File

@@ -0,0 +1,85 @@
import { FetchError } from "ofetch";
import { makeUrl, proxiedFetch } from "../helpers/fetch";
import {
formatJWMeta,
JWMediaResult,
JWSeasonMetaResult,
JW_API_BASE,
mediaTypeToJW,
} from "./justwatch";
import { MWMediaMeta, MWMediaType } from "./types";
type JWExternalIdType =
| "eidr"
| "imdb_latest"
| "imdb"
| "tmdb_latest"
| "tmdb"
| "tms";
interface JWExternalId {
provider: JWExternalIdType;
external_id: string;
}
interface JWDetailedMeta extends JWMediaResult {
external_ids: JWExternalId[];
}
export interface DetailedMeta {
meta: MWMediaMeta;
tmdbId: string;
imdbId: string;
}
export async function getMetaFromId(
type: MWMediaType,
id: string,
seasonId?: string
): Promise<DetailedMeta | null> {
const queryType = mediaTypeToJW(type);
let data: JWDetailedMeta;
try {
const url = makeUrl("/content/titles/{type}/{id}/locale/en_US", {
type: queryType,
id,
});
data = await proxiedFetch<JWDetailedMeta>(url, { baseURL: JW_API_BASE });
} catch (err) {
if (err instanceof FetchError) {
// 400 and 404 are treated as not found
if (err.statusCode === 400 || err.statusCode === 404) return null;
}
throw err;
}
let imdbId = data.external_ids.find(
(v) => v.provider === "imdb_latest"
)?.external_id;
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");
let seasonData: JWSeasonMetaResult | undefined;
if (data.object_type === "show") {
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";
const url = makeUrl("/content/titles/show_season/{id}/locale/en_US", {
id: seasonToScrape,
});
seasonData = await proxiedFetch<any>(url, { baseURL: JW_API_BASE });
}
return {
meta: formatJWMeta(data, seasonData),
imdbId,
tmdbId,
};
}

View File

@@ -0,0 +1,112 @@
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types";
export const JW_API_BASE = "https://apis.justwatch.com";
export const JW_IMAGE_BASE = "https://images.justwatch.com";
export type JWContentTypes = "movie" | "show";
export type JWSeasonShort = {
title: string;
id: number;
season_number: number;
};
export type JWEpisodeShort = {
title: string;
id: number;
episode_number: number;
};
export type JWMediaResult = {
title: string;
poster?: string;
id: number;
original_release_year?: number;
jw_entity_id: string;
object_type: JWContentTypes;
seasons?: JWSeasonShort[];
};
export type JWSeasonMetaResult = {
title: string;
id: string;
season_number: number;
episodes: JWEpisodeShort[];
};
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
if (type === MWMediaType.MOVIE) return "movie";
if (type === MWMediaType.SERIES) return "show";
throw new Error("unsupported type");
}
export function JWMediaToMediaType(type: string): MWMediaType {
if (type === "movie") return MWMediaType.MOVIE;
if (type === "show") return MWMediaType.SERIES;
throw new Error("unsupported type");
}
export function formatJWMeta(
media: JWMediaResult,
season?: JWSeasonMetaResult
): MWMediaMeta {
const type = JWMediaToMediaType(media.object_type);
let seasons: undefined | MWSeasonMeta[];
if (type === MWMediaType.SERIES) {
seasons = media.seasons
?.sort((a, b) => a.season_number - b.season_number)
.map(
(v): MWSeasonMeta => ({
id: v.id.toString(),
number: v.season_number,
title: v.title,
})
);
}
return {
title: media.title,
id: media.id.toString(),
year: media.original_release_year?.toString(),
poster: media.poster
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
: undefined,
type,
seasons: seasons as any,
seasonData: season
? ({
id: season.id.toString(),
number: season.season_number,
title: season.title,
episodes: season.episodes
.sort((a, b) => a.episode_number - b.episode_number)
.map((v) => ({
id: v.id.toString(),
number: v.episode_number,
title: v.title,
})),
} as any)
: (undefined as any),
};
}
export function JWMediaToId(media: MWMediaMeta): string {
return ["JW", mediaTypeToJW(media.type), media.id].join("-");
}
export function decodeJWId(
paramId: string
): { id: string; type: MWMediaType } | null {
const [prefix, type, id] = paramId.split("-", 3);
if (prefix !== "JW") return null;
let mediaType;
try {
mediaType = JWMediaToMediaType(type);
} catch {
return null;
}
return {
type: mediaType,
id,
};
}

View File

@@ -0,0 +1,58 @@
import { SimpleCache } from "@/utils/cache";
import { proxiedFetch } from "../helpers/fetch";
import {
formatJWMeta,
JWContentTypes,
JWMediaResult,
JW_API_BASE,
mediaTypeToJW,
} from "./justwatch";
import { MWMediaMeta, MWQuery } from "./types";
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
cache.setCompare((a, b) => {
return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim();
});
cache.initialize();
type JWSearchQuery = {
content_types: JWContentTypes[];
page: number;
page_size: number;
query: string;
};
type JWPage<T> = {
items: T[];
page: number;
page_size: number;
total_pages: number;
total_results: number;
};
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
const { searchQuery, type } = query;
const contentType = mediaTypeToJW(type);
const body: JWSearchQuery = {
content_types: [contentType],
page: 1,
query: searchQuery,
page_size: 40,
};
const data = await proxiedFetch<JWPage<JWMediaResult>>(
"/content/titles/en_US/popular",
{
baseURL: JW_API_BASE,
params: {
body: JSON.stringify(body),
},
}
);
const returnData = data.items.map<MWMediaMeta>((v) => formatJWMeta(v));
cache.set(query, returnData, 3600); // cache for an hour
return returnData;
}

View File

@@ -0,0 +1,47 @@
export enum MWMediaType {
MOVIE = "movie",
SERIES = "series",
ANIME = "anime",
}
export type MWSeasonMeta = {
id: string;
number: number;
title: string;
};
export type MWSeasonWithEpisodeMeta = {
id: string;
number: number;
title: string;
episodes: {
id: string;
number: number;
title: string;
}[];
};
type MWMediaMetaBase = {
title: string;
id: string;
year?: string;
poster?: string;
};
type MWMediaMetaSpecific =
| {
type: MWMediaType.MOVIE | MWMediaType.ANIME;
seasons: undefined;
}
| {
type: MWMediaType.SERIES;
seasons: MWSeasonMeta[];
seasonData: MWSeasonWithEpisodeMeta;
};
export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific;
export interface MWQuery {
searchQuery: string;
type: MWMediaType;
}

View File

@@ -0,0 +1,128 @@
import { compareTitle } from "@/utils/titleMatch";
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const flixHqBase = "https://api.consumet.org/meta/tmdb";
interface FLIXMediaBase {
id: number;
title: string;
url: string;
image: string;
type: "Movie" | "TV Series";
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,
};
enum FlixHQMediaType {
MOVIE = "movie",
SERIES = "series",
}
registerProvider({
id: "flixhq",
displayName: "FlixHQ",
rank: 100,
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)}`,
{
baseURL: flixHqBase,
}
);
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
if (v.type !== "Movie" && v.type !== "TV Series") return false;
return (
compareTitle(v.title, media.meta.title) &&
v.releaseDate === media.meta.year
);
});
if (!foundItem) throw new Error("No watchable item found");
// get media info
progress(25);
const mediaInfo = await proxiedFetch<any>(`/info/${foundItem.id}`, {
baseURL: flixHqBase,
params: {
type:
media.meta.type === MWMediaType.MOVIE
? FlixHQMediaType.MOVIE
: FlixHQMediaType.SERIES,
},
});
if (!mediaInfo.id) throw new Error("No watchable item found");
// get stream info from media
progress(75);
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");
const watchInfo = await proxiedFetch<any>(`/watch/${episodeId}`, {
baseURL: flixHqBase,
params: {
id: mediaInfo.id,
},
});
if (!watchInfo.sources) throw new Error("No watchable item found");
// get best quality source
// comes sorted by quality in descending order
const source = watchInfo.sources[0];
return {
embeds: [],
stream: {
streamUrl: source.url,
quality: qualityMap[source.quality],
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
captions: watchInfo.subtitles
.filter(
(x: { url: string; lang: string }) => !x.lang.includes("(maybe)")
)
.map(castSubtitles),
},
};
},
});

View File

@@ -1,15 +1,10 @@
import { unpack } from "unpacker"; import { unpack } from "unpacker";
import CryptoJS from "crypto-js"; import CryptoJS from "crypto-js";
import {
MWMediaProvider,
MWMediaType,
MWPortableMedia,
MWMediaStream,
MWQuery,
MWProviderMediaResult,
} from "@/providers/types";
import { CORS_PROXY_URL } from "@/mw_constants"; import { registerProvider } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types";
import { MWStreamQuality } from "@/backend/helpers/streams";
import { proxiedFetch } from "../helpers/fetch";
const format = { const format = {
stringify: (cipher: any) => { stringify: (cipher: any) => {
@@ -37,46 +32,24 @@ const format = {
}, },
}; };
export const gDrivePlayerScraper: MWMediaProvider = { registerProvider({
id: "gdriveplayer", id: "gdriveplayer",
enabled: true,
type: [MWMediaType.MOVIE],
displayName: "gdriveplayer", displayName: "gdriveplayer",
disabled: true,
rank: 69,
type: [MWMediaType.MOVIE],
async getMediaFromPortable( async scrape({ progress, media: { imdbId } }) {
media: MWPortableMedia progress(10);
): Promise<MWProviderMediaResult> { const streamRes = await proxiedFetch<string>(
const res = await fetch( "https://database.gdriveplayer.us/player.php",
`${CORS_PROXY_URL}https://api.gdriveplayer.us/v1/imdb/${media.mediaId}` {
).then((d) => d.json()); params: {
imdb: imdbId,
return { },
...media, }
title: res.Title,
year: res.Year,
} as MWProviderMediaResult;
},
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
const searchRes = await fetch(
`${CORS_PROXY_URL}https://api.gdriveplayer.us/v1/movie/search?title=${query.searchQuery}`
).then((d) => d.json());
const results: MWProviderMediaResult[] = (searchRes || []).map(
(item: any) => ({
title: item.title,
year: item.year,
mediaId: item.imdb,
})
); );
progress(90);
return results;
},
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
const streamRes = await fetch(
`${CORS_PROXY_URL}https://database.gdriveplayer.us/player.php?imdb=${media.mediaId}`
).then((d) => d.text());
const page = new DOMParser().parseFromString(streamRes, "text/html"); const page = new DOMParser().parseFromString(streamRes, "text/html");
const script: HTMLElement | undefined = Array.from( const script: HTMLElement | undefined = Array.from(
@@ -99,6 +72,7 @@ export const gDrivePlayerScraper: MWMediaProvider = {
{ format } { format }
).toString(CryptoJS.enc.Utf8) ).toString(CryptoJS.enc.Utf8)
); );
// eslint-disable-next-line // eslint-disable-next-line
const sources = JSON.parse( const sources = JSON.parse(
JSON.stringify( JSON.stringify(
@@ -114,6 +88,18 @@ export const gDrivePlayerScraper: MWMediaProvider = {
const source = sources[sources.length - 1]; const source = sources[sources.length - 1];
/// END /// END
return { url: `https:${source.file}`, type: source.type, captions: [] }; let quality;
if (source.label === "720p") quality = MWStreamQuality.Q720P;
else quality = MWStreamQuality.QUNKNOWN;
return {
stream: {
streamUrl: `https:${source.file}`,
type: source.type,
quality,
captions: [],
},
embeds: [],
};
}, },
}; });

View File

@@ -0,0 +1,235 @@
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWMediaType } from "../metadata/types";
const HOST = "m4ufree.com";
const URL_BASE = `https://${HOST}`;
const URL_SEARCH = `${URL_BASE}/search`;
const URL_AJAX = `${URL_BASE}/ajax`;
const URL_AJAX_TV = `${URL_BASE}/ajaxtv`;
// * Years can be in one of 4 formats:
// * - "startyear" (for movies, EX: 2022)
// * - "startyear-" (for TV series which has not ended, EX: 2022-)
// * - "startyear-endyear" (for TV series which has ended, EX: 2022-2023)
// * - "startyearendyear" (for TV series which has ended, EX: 20222023)
const REGEX_TITLE_AND_YEAR = /(.*) \(?(\d*|\d*-|\d*-\d*)\)?$/;
const REGEX_TYPE = /.*-(movie|tvshow)-online-free-m4ufree\.html/;
const REGEX_COOKIES = /XSRF-TOKEN=(.*?);.*laravel_session=(.*?);/;
const REGEX_SEASON_EPISODE = /S(\d*)-E(\d*)/;
function toDom(html: string) {
return new DOMParser().parseFromString(html, "text/html");
}
registerProvider({
id: "m4ufree",
displayName: "m4ufree",
rank: -1,
disabled: true, // Disables because the redirector URLs it returns will throw 404 / 403 depending on if you view it in the browser or fetch it respectively. It just does not work.
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, type, episode: episodeId, season: seasonId }) {
const season =
media.meta.seasons?.find((s) => s.id === seasonId)?.number || 1;
const episode =
media.meta.type === MWMediaType.SERIES
? media.meta.seasonData.episodes.find((ep) => ep.id === episodeId)
?.number || 1
: undefined;
const embeds: MWEmbed[] = [];
/*
, {
responseType: "text" as any,
}
*/
const responseText = await proxiedFetch<string>(
`${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html`
);
let dom = toDom(responseText);
const searchResults = [...dom.querySelectorAll(".item")]
.map((element) => {
const tooltipText = element.querySelector(".tiptitle p")?.innerHTML;
if (!tooltipText) return;
let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText);
if (!regexResult || !regexResult[1] || !regexResult[2]) {
return;
}
const title = regexResult[1];
const year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year
const a = element.querySelector("a");
if (!a) return;
const href = a.href;
regexResult = REGEX_TYPE.exec(href);
if (!regexResult || !regexResult[1]) {
return;
}
let scraperDeterminedType = regexResult[1];
scraperDeterminedType =
scraperDeterminedType === "tvshow" ? "show" : "movie"; // * Map to Trakt type
return { type: scraperDeterminedType, title, year, href };
})
.filter((item) => item);
const mediaInResults = searchResults.find(
(item) =>
item &&
item.title === media.meta.title &&
item.year.toString() === media.meta.year
);
if (!mediaInResults) {
// * Nothing found
return {
embeds,
};
}
let cookies: string | null = "";
const responseTextFromMedia = await proxiedFetch<string>(
mediaInResults.href,
{
onResponse(context) {
cookies = context.response.headers.get("X-Set-Cookie");
},
}
);
dom = toDom(responseTextFromMedia);
let regexResult = REGEX_COOKIES.exec(cookies);
if (!regexResult || !regexResult[1] || !regexResult[2]) {
// * DO SOMETHING?
throw new Error("No regexResults, yikesssssss kinda gross idk");
}
const cookieHeader = `XSRF-TOKEN=${regexResult[1]}; laravel_session=${regexResult[2]}`;
const token = dom
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
if (!token) return { embeds };
if (type === MWMediaType.SERIES) {
// * Get the season/episode data
const episodes = [...dom.querySelectorAll(".episode")]
.map((element) => {
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML);
if (!regexResult || !regexResult[1] || !regexResult[2]) {
return;
}
const newEpisode = Number(regexResult[1]);
const newSeason = Number(regexResult[2]);
return {
id: element.getAttribute("idepisode"),
episode: newEpisode,
season: newSeason,
};
})
.filter((item) => item);
const ep = episodes.find(
(newEp) => newEp && newEp.episode === episode && newEp.season === season
);
if (!ep) return { embeds };
const form = `idepisode=${ep.id}&_token=${token}`;
const response = await proxiedFetch<string>(URL_AJAX_TV, {
method: "POST",
headers: {
Accept: "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Sec-CH-UA":
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
"Sec-CH-UA-Mobile": "?0",
"Sec-CH-UA-Platform": '"Linux"',
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
"X-Cookie": cookieHeader,
"X-Origin": URL_BASE,
"X-Referer": mediaInResults.href,
},
body: form,
});
dom = toDom(response);
}
const servers = [...dom.querySelectorAll(".singlemv")].map((element) =>
element.getAttribute("data")
);
for (const server of servers) {
const form = `m4u=${server}&_token=${token}`;
const response = await proxiedFetch<string>(URL_AJAX, {
method: "POST",
headers: {
Accept: "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Sec-CH-UA":
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
"Sec-CH-UA-Mobile": "?0",
"Sec-CH-UA-Platform": '"Linux"',
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
"X-Cookie": cookieHeader,
"X-Origin": URL_BASE,
"X-Referer": mediaInResults.href,
},
body: form,
});
const serverDom = toDom(response);
const link = serverDom.querySelector("iframe")?.src;
const getEmbedType = (url: string) => {
if (url.startsWith("https://streamm4u.club"))
return MWEmbedType.STREAMM4U;
if (url.startsWith("https://play.playm4u.xyz"))
return MWEmbedType.PLAYM4U;
return null;
};
if (!link) continue;
const embedType = getEmbedType(link);
if (embedType) {
embeds.push({
url: link,
type: embedType,
});
}
}
console.log(embeds);
return {
embeds,
};
},
});

View File

@@ -0,0 +1,153 @@
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const netfilmBase = "https://net-film.vercel.app";
const qualityMap: Record<number, MWStreamQuality> = {
360: MWStreamQuality.Q360P,
540: MWStreamQuality.Q540P,
480: MWStreamQuality.Q480P,
720: MWStreamQuality.Q720P,
1080: MWStreamQuality.Q1080P,
};
registerProvider({
id: "netfilm",
displayName: "NetFilm",
rank: 15,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
// search for relevant item
const searchResponse = await proxiedFetch<any>(
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
{
baseURL: netfilmBase,
}
);
const searchResults = searchResponse.data.results;
progress(25);
if (media.meta.type === MWMediaType.MOVIE) {
const foundItem = searchResults.find((v: any) => {
return v.name === media.meta.title && v.releaseTime === media.meta.year;
});
if (!foundItem) throw new Error("No watchable item found");
const netfilmId = foundItem.id;
// get stream info from media
progress(75);
const watchInfo = await proxiedFetch<any>(
`/api/episode?id=${netfilmId}`,
{
baseURL: netfilmBase,
}
);
const data = watchInfo.data;
// get best quality source
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
.replace("akm-cdn", "aws-cdn")
.replace("gg-cdn", "aws-cdn"),
quality: qualityMap[source.quality],
type: MWStreamType.HLS,
captions: mappedCaptions,
},
};
}
if (media.meta.type !== MWMediaType.SERIES)
throw new Error("Unsupported type");
const desiredSeason = media.meta.seasonData.number;
const searchItems = searchResults
.filter((v: any) => {
return v.name.includes(media.meta.title);
})
.map((v: any) => {
return {
...v,
season: parseInt(v.name.split(" ").at(-1), 10) || 1,
};
});
const foundItem = searchItems.find((v: any) => {
return v.season === desiredSeason;
});
progress(50);
const seasonDetail = await proxiedFetch<any>(
`/api/detail?id=${foundItem.id}&category=${foundItem.categoryTag[0].id}`,
{
baseURL: netfilmBase,
}
);
const episodeNo = media.meta.seasonData.episodes.find(
(v: any) => v.id === episode
)?.number;
const episodeData = seasonDetail.data.episodeVo.find(
(v: any) => v.seriesNo === episodeNo
);
progress(75);
const episodeStream = await proxiedFetch<any>(
`/api/episode?id=${foundItem.id}&category=1&episode=${episodeData.id}`,
{
baseURL: netfilmBase,
}
);
const data = episodeStream.data;
// get best quality source
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
.replace("akm-cdn", "aws-cdn")
.replace("gg-cdn", "aws-cdn"),
quality: qualityMap[source.quality],
type: MWStreamType.HLS,
captions: mappedCaptions,
},
};
},
});

View File

@@ -0,0 +1,249 @@
import { registerProvider } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types";
import { customAlphabet } from "nanoid";
import CryptoJS from "crypto-js";
import { proxiedFetch } from "@/backend/helpers/fetch";
import {
MWCaption,
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "@/backend/helpers/streams";
import { compareTitle } from "@/utils/titleMatch";
const nanoid = customAlphabet("0123456789abcdef", 32);
const qualityMap = {
"360p": MWStreamQuality.Q360P,
"480p": MWStreamQuality.Q480P,
"720p": MWStreamQuality.Q720P,
"1080p": MWStreamQuality.Q1080P,
};
type QualityInMap = keyof typeof qualityMap;
// CONSTANTS, read below (taken from og)
// We do not want content scanners to notice this scraping going on so we've hidden all constants
// The source has its origins in China so I added some extra security with banned words
// Mayhaps a tiny bit unethical, but this source is just too good :)
// If you are copying this code please use precautions so they do not change their api.
const iv = atob("d0VpcGhUbiE=");
const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2");
const apiUrls = [
atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="),
atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="),
];
const appKey = atob("bW92aWVib3g=");
const appId = atob("Y29tLnRkby5zaG93Ym94");
// cryptography stuff
const crypto = {
encrypt(str: string) {
return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), {
iv: CryptoJS.enc.Utf8.parse(iv),
}).toString();
},
getVerify(str: string, str2: string, str3: string) {
if (str) {
return CryptoJS.MD5(
CryptoJS.MD5(str2).toString() + str3 + str
).toString();
}
return null;
},
};
// get expire time
const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12);
// sending requests
const get = (data: object, altApi = false) => {
const defaultData = {
childmode: "0",
app_version: "11.5",
appid: appId,
lang: "en",
expired_date: `${expiry()}`,
platform: "android",
channel: "Website",
};
const encryptedData = crypto.encrypt(
JSON.stringify({
...defaultData,
...data,
})
);
const appKeyHash = CryptoJS.MD5(appKey).toString();
const verify = crypto.getVerify(encryptedData, appKey, key);
const body = JSON.stringify({
app_key: appKeyHash,
verify,
encrypt_data: encryptedData,
});
const b64Body = btoa(body);
const formatted = new URLSearchParams();
formatted.append("data", b64Body);
formatted.append("appid", "27");
formatted.append("platform", "android");
formatted.append("version", "129");
formatted.append("medium", "Website");
const requestUrl = altApi ? apiUrls[1] : apiUrls[0];
return proxiedFetch<any>(requestUrl, {
method: "POST",
parseResponse: JSON.parse,
headers: {
Platform: "android",
"Content-Type": "application/x-www-form-urlencoded",
},
body: `${formatted.toString()}&token${nanoid()}`,
});
};
// Find best resolution
const getBestQuality = (list: any[]) => {
return (
list.find((quality: any) => quality.quality === "1080p" && quality.path) ??
list.find((quality: any) => quality.quality === "720p" && quality.path) ??
list.find((quality: any) => quality.quality === "480p" && quality.path) ??
list.find((quality: any) => quality.quality === "360p" && quality.path)
);
};
registerProvider({
id: "superstream",
displayName: "Superstream",
rank: 200,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
// Find Superstream ID for show
const searchQuery = {
module: "Search3",
page: "1",
type: "all",
keyword: media.meta.title,
pagelimit: "20",
};
const searchRes = (await get(searchQuery, true)).data;
progress(33);
const superstreamEntry = searchRes.find(
(res: any) =>
compareTitle(res.title, media.meta.title) &&
res.year === Number(media.meta.year)
);
if (!superstreamEntry) throw new Error("No entry found on SuperStream");
const superstreamId = superstreamEntry.id;
// Movie logic
if (media.meta.type === MWMediaType.MOVIE) {
const apiQuery = {
uid: "",
module: "Movie_downloadurl_v3",
mid: superstreamId,
oss: "1",
group: "",
};
const mediaRes = (await get(apiQuery)).data;
progress(50);
const hdQuality = getBestQuality(mediaRes.list);
if (!hdQuality) throw new Error("No quality could be found.");
const subtitleApiQuery = {
fid: hdQuality.fid,
uid: "",
module: "Movie_srt_list_v2",
mid: superstreamId,
};
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,
};
}
);
return {
embeds: [],
stream: {
streamUrl: hdQuality.path,
quality: qualityMap[hdQuality.quality as QualityInMap],
type: MWStreamType.MP4,
captions: mappedCaptions,
},
};
}
if (media.meta.type !== MWMediaType.SERIES)
throw new Error("Unsupported type");
// Fetch requested episode
const apiQuery = {
uid: "",
module: "TV_downloadurl_v3",
tid: superstreamId,
season: media.meta.seasonData.number.toString(),
episode: (
media.meta.seasonData.episodes.find(
(episodeInfo) => episodeInfo.id === episode
)?.number ?? 1
).toString(),
oss: "1",
group: "",
};
const mediaRes = (await get(apiQuery)).data;
progress(66);
const hdQuality = getBestQuality(mediaRes.list);
if (!hdQuality) throw new Error("No quality could be found.");
const subtitleApiQuery = {
fid: hdQuality.fid,
uid: "",
module: "TV_srt_list_v2",
episode:
media.meta.seasonData.episodes.find(
(episodeInfo) => episodeInfo.id === episode
)?.number ?? 1,
tid: superstreamId,
season: media.meta.seasonData.number.toString(),
};
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,
};
});
return {
embeds: [],
stream: {
quality: qualityMap[
hdQuality.quality as QualityInMap
] as MWStreamQuality,
streamUrl: hdQuality.path,
type: MWStreamType.MP4,
captions: mappedCaptions,
},
};
},
});

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

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

25
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { Icon, Icons } from "@/components/Icon";
import { ReactNode } from "react";
interface Props {
icon?: Icons;
onClick?: () => void;
children?: ReactNode;
}
export function Button(props: Props) {
return (
<button
type="button"
onClick={props.onClick}
className="inline-flex items-center justify-center rounded-lg bg-white px-8 py-3 font-bold text-black transition-[transform,background-color] duration-100 hover:bg-gray-200 active:scale-105 md:px-16"
>
{props.icon ? (
<span className="mr-3 hidden md:inline-block">
<Icon icon={props.icon} />
</span>
) : null}
{props.children}
</button>
);
}

View File

@@ -57,5 +57,5 @@ export function Dropdown(props: DropdownProps) {
)} )}
</Listbox> </Listbox>
</div> </div>
) );
} }

View File

@@ -1,12 +1,16 @@
import { memo, useEffect, useRef } from "react";
export enum Icons { export enum Icons {
SEARCH = "search", SEARCH = "search",
BOOKMARK = "bookmark", BOOKMARK = "bookmark",
BOOKMARK_OUTLINE = "bookmark_outline",
CLOCK = "clock", CLOCK = "clock",
EYE_SLASH = "eyeSlash", EYE_SLASH = "eyeSlash",
ARROW_LEFT = "arrowLeft", ARROW_LEFT = "arrowLeft",
ARROW_RIGHT = "arrowRight", ARROW_RIGHT = "arrowRight",
CHEVRON_DOWN = "chevronDown", CHEVRON_DOWN = "chevronDown",
CHEVRON_RIGHT = "chevronRight", CHEVRON_RIGHT = "chevronRight",
CHEVRON_LEFT = "chevronLeft",
CLAPPER_BOARD = "clapperBoard", CLAPPER_BOARD = "clapperBoard",
FILM = "film", FILM = "film",
DRAGON = "dragon", DRAGON = "dragon",
@@ -14,6 +18,28 @@ export enum Icons {
MOVIE_WEB = "movieWeb", MOVIE_WEB = "movieWeb",
DISCORD = "discord", DISCORD = "discord",
GITHUB = "github", GITHUB = "github",
PLAY = "play",
PAUSE = "pause",
EXPAND = "expand",
COMPRESS = "compress",
VOLUME = "volume",
VOLUME_X = "volume_x",
X = "x",
EDIT = "edit",
AIRPLAY = "airplay",
EPISODES = "episodes",
SKIP_FORWARD = "skip_forward",
SKIP_BACKWARD = "skip_backward",
FILE = "file",
CAPTIONS = "captions",
LINK = "link",
CASTING = "casting",
CIRCLE_EXCLAMATION = "circle_exclamation",
DOWNLOAD = "download",
GEAR = "gear",
WATCH_PARTY = "watch_party",
PICTURE_IN_PICTURE = "pictureInPicture",
CHECKMARK = "checkmark",
} }
export interface IconProps { export interface IconProps {
@@ -29,6 +55,7 @@ const iconList: Record<Icons, string> = {
arrowLeft: `<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-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>`, arrowLeft: `<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-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>`,
chevronDown: `<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-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>`, chevronDown: `<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-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
chevronRight: `<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-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>`, chevronRight: `<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-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-left"><polyline points="15 18 9 12 15 6"></polyline></svg>`,
clapperBoard: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`, clapperBoard: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`,
film: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M463.1 32h-416C21.49 32-.0001 53.49-.0001 80v352c0 26.51 21.49 48 47.1 48h416c26.51 0 48-21.49 48-48v-352C511.1 53.49 490.5 32 463.1 32zM111.1 408c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 408zM111.1 280c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM111.1 152c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 152zM351.1 400c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V400zM351.1 208c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V208zM463.1 408c0 4.418-3.582 8-8 8h-47.1c-4.418 0-7.1-3.582-7.1-8l0-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V408zM463.1 280c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM463.1 152c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8l0-48c0-4.418 3.582-8 7.1-8h47.1c4.418 0 8 3.582 8 8V152z"/></svg>`, film: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M463.1 32h-416C21.49 32-.0001 53.49-.0001 80v352c0 26.51 21.49 48 47.1 48h416c26.51 0 48-21.49 48-48v-352C511.1 53.49 490.5 32 463.1 32zM111.1 408c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 408zM111.1 280c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM111.1 152c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 152zM351.1 400c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V400zM351.1 208c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V208zM463.1 408c0 4.418-3.582 8-8 8h-47.1c-4.418 0-7.1-3.582-7.1-8l0-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V408zM463.1 280c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM463.1 152c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8l0-48c0-4.418 3.582-8 7.1-8h47.1c4.418 0 8 3.582 8 8V152z"/></svg>`,
dragon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M18.43 255.8L192 224L100.8 292.6C90.67 302.8 97.8 320 112 320h222.7c-9.499-26.5-14.75-54.5-14.75-83.38V194.2L200.3 106.8C176.5 90.88 145 92.75 123.3 111.2l-117.5 116.4C-6.562 238 2.436 258 18.43 255.8zM575.2 289.9l-100.7-50.25c-16.25-8.125-26.5-24.75-26.5-43V160h63.99l28.12 22.62C546.1 188.6 554.2 192 562.7 192h30.1c11.1 0 23.12-6.875 28.5-17.75l14.37-28.62c5.374-10.87 4.25-23.75-2.999-33.5l-74.49-99.37C552.1 4.75 543.5 0 533.5 0H296C288.9 0 285.4 8.625 290.4 13.62L351.1 64L292.4 88.75c-5.874 3-5.874 11.37 0 14.37L351.1 128l-.0011 108.6c0 72 35.99 139.4 95.99 179.4c-195.6 6.75-344.4 41-434.1 60.88c-8.124 1.75-13.87 9-13.87 17.38C.0463 504 8.045 512 17.79 512h499.1c63.24 0 119.6-47.5 122.1-110.8C642.3 354 617.1 310.9 575.2 289.9zM489.1 66.25l45.74 11.38c-2.75 11-12.5 18.88-24.12 18.25C497.7 95.25 484.8 83.38 489.1 66.25z"/></svg>`, dragon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M18.43 255.8L192 224L100.8 292.6C90.67 302.8 97.8 320 112 320h222.7c-9.499-26.5-14.75-54.5-14.75-83.38V194.2L200.3 106.8C176.5 90.88 145 92.75 123.3 111.2l-117.5 116.4C-6.562 238 2.436 258 18.43 255.8zM575.2 289.9l-100.7-50.25c-16.25-8.125-26.5-24.75-26.5-43V160h63.99l28.12 22.62C546.1 188.6 554.2 192 562.7 192h30.1c11.1 0 23.12-6.875 28.5-17.75l14.37-28.62c5.374-10.87 4.25-23.75-2.999-33.5l-74.49-99.37C552.1 4.75 543.5 0 533.5 0H296C288.9 0 285.4 8.625 290.4 13.62L351.1 64L292.4 88.75c-5.874 3-5.874 11.37 0 14.37L351.1 128l-.0011 108.6c0 72 35.99 139.4 95.99 179.4c-195.6 6.75-344.4 41-434.1 60.88c-8.124 1.75-13.87 9-13.87 17.38C.0463 504 8.045 512 17.79 512h499.1c63.24 0 119.6-47.5 122.1-110.8C642.3 354 617.1 310.9 575.2 289.9zM489.1 66.25l45.74 11.38c-2.75 11-12.5 18.88-24.12 18.25C497.7 95.25 484.8 83.38 489.1 66.25z"/></svg>`,
@@ -37,13 +64,52 @@ const iconList: Record<Icons, string> = {
movieWeb: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20.927 20.927"><path d="M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z" transform="translate(10.018 -7.425) rotate(45)" fill="currentColor"/></svg>`, movieWeb: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20.927 20.927"><path d="M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z" transform="translate(10.018 -7.425) rotate(45)" fill="currentColor"/></svg>`,
discord: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`, discord: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`,
github: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 496 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>`, github: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 496 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>`,
play: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" style="transform: translateX(5%)" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>`,
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M48 64C21.5 64 0 85.5 0 112V400c0 26.5 21.5 48 48 48H80c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zm192 0c-26.5 0-48 21.5-48 48V400c0 26.5 21.5 48 48 48h32c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H240z"/></svg>`,
expand: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64v96c0 17.7 14.3 32 32 32s32-14.3 32-32V96h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H64V352zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64v64c0 17.7 14.3 32 32 32s32-14.3 32-32V64c0-17.7-14.3-32-32-32H320zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H320c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V352z"/></svg>`,
compress: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M160 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V64zM32 320c-17.7 0-32 14.3-32 32s14.3 32 32 32H96v64c0 17.7 14.3 32 32 32s32-14.3 32-32V352c0-17.7-14.3-32-32-32H32zM352 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H352V64zM320 320c-17.7 0-32 14.3-32 32v96c0 17.7 14.3 32 32 32s32-14.3 32-32V384h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H320z"/></svg>`,
volume: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>`,
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"/></svg>`,
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>`,
bookmark_outline: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M336 0h-288C21.49 0 0 21.49 0 48v431.9c0 24.7 26.79 40.08 48.12 27.64L192 423.6l143.9 83.93C357.2 519.1 384 504.6 384 479.9V48C384 21.49 362.5 0 336 0zM336 452L192 368l-144 84V54C48 50.63 50.63 48 53.1 48h276C333.4 48 336 50.63 336 54V452z"/></svg>`,
airplay: `<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-airplay"><path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"></path><polygon fill="currentColor" points="12 15 17 21 7 21 12 15"></polygon></svg>`,
episodes: `<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4C1.34315 4 0 5.34314 0 7V13.9496C0 15.6065 1.34315 16.9496 3 16.9496H5.86645V14.9496H3C2.44772 14.9496 2 14.5019 2 13.9496V7C2 6.44771 2.44771 6 3 6H16.0327C16.585 6 17.0327 6.44772 17.0327 7V9.86645H19.0327V7C19.0327 5.34315 17.6896 4 16.0327 4H3Z" fill="currentColor"/><rect x="5.89929" y="10.5444" width="17" height="10" rx="2" stroke="currentColor" stroke-width="2"/></svg>`,
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 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>`,
}; };
export function Icon(props: IconProps) { function ChromeCastButton() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const tag = document.createElement("google-cast-launcher");
tag.setAttribute("id", "castbutton");
ref.current?.appendChild(tag);
}, []);
return <div ref={ref} className="h-6" />;
}
export const Icon = memo((props: IconProps) => {
if (props.icon === Icons.CASTING) {
return <ChromeCastButton />;
}
return ( return (
<span <span
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
className={props.className} className={props.className}
/> />
); );
} });

View File

@@ -0,0 +1,20 @@
import { Transition } from "@/components/Transition";
import { Helmet } from "react-helmet";
export function Overlay(props: { children: React.ReactNode }) {
return (
<>
<Helmet>
<body data-no-scroll />
</Helmet>
<div className="fixed inset-0 z-[99999]">
<Transition
animation="fade"
className="absolute inset-0 bg-[rgba(8,6,18,0.85)]"
isChild
/>
{props.children}
</div>
</>
);
}

View File

@@ -1,8 +1,8 @@
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
import { useState } from "react"; import { useState } from "react";
import { MWMediaType, MWQuery } from "@/providers";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DropdownButton } from "./buttons/DropdownButton"; import { DropdownButton } from "./buttons/DropdownButton";
import { Icons } from "./Icon"; import { Icon, Icons } from "./Icon";
import { TextInputControl } from "./text-inputs/TextInputControl"; import { TextInputControl } from "./text-inputs/TextInputControl";
export interface SearchBarProps { export interface SearchBarProps {
@@ -37,42 +37,43 @@ export function SearchBarInput(props: SearchBarProps) {
} }
return ( return (
<div className="flex flex-col items-center gap-4 rounded-[28px] bg-denim-300 px-4 py-4 transition-colors focus-within:bg-denim-400 hover:bg-denim-400 sm:flex-row sm:py-2 sm:pl-8 sm:pr-2"> <div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
<div className="pointer-events-none absolute left-5 top-0 bottom-0 flex max-h-14 items-center">
<Icon icon={Icons.SEARCH} />
</div>
<TextInputControl <TextInputControl
onUnFocus={props.onUnFocus} onUnFocus={props.onUnFocus}
onChange={(val) => setSearch(val)} onChange={(val) => setSearch(val)}
value={props.value.searchQuery} value={props.value.searchQuery}
className="w-full flex-1 bg-transparent text-white placeholder-denim-700 focus:outline-none" className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2"
placeholder={props.placeholder} placeholder={props.placeholder}
/> />
<DropdownButton <div className="px-4 py-4 pt-0 sm:py-2 sm:px-2">
icon={Icons.SEARCH} <DropdownButton
open={dropdownOpen} icon={Icons.SEARCH}
setOpen={(val) => setDropdownOpen(val)} open={dropdownOpen}
selectedItem={props.value.type} setOpen={(val) => setDropdownOpen(val)}
setSelectedItem={(val) => setType(val)} selectedItem={props.value.type}
options={[ setSelectedItem={(val) => setType(val)}
{ options={[
id: MWMediaType.MOVIE, {
name: t('searchBar.movie'), id: MWMediaType.MOVIE,
icon: Icons.FILM, name: t("searchBar.movie"),
}, icon: Icons.FILM,
{ },
id: MWMediaType.SERIES, {
name: t('searchBar.series'), id: MWMediaType.SERIES,
icon: Icons.CLAPPER_BOARD, name: t("searchBar.series"),
}, icon: Icons.CLAPPER_BOARD,
// { },
// id: MWMediaType.ANIME, ]}
// name: "Anime", onClick={() => setDropdownOpen((old) => !old)}
// icon: Icons.DRAGON, >
// }, {props.buttonText || t("searchBar.search")}
]} </DropdownButton>
onClick={() => setDropdownOpen((old) => !old)} </div>
>
{props.buttonText || t('searchBar.search')}
</DropdownButton>
</div> </div>
); );
} }

View File

@@ -0,0 +1,103 @@
import { Fragment, ReactNode } from "react";
import {
Transition as HeadlessTransition,
TransitionClasses,
} from "@headlessui/react";
type TransitionAnimations =
| "slide-down"
| "slide-full-left"
| "slide-full-right"
| "slide-up"
| "fade"
| "none";
interface Props {
show?: boolean;
durationClass?: string;
animation: TransitionAnimations;
className?: string;
children?: ReactNode;
isChild?: boolean;
}
function getClasses(
animation: TransitionAnimations,
duration: string
): TransitionClasses {
if (animation === "slide-down") {
return {
leave: `transition-[transform,opacity] ${duration}`,
leaveFrom: "opacity-100 translate-y-0",
leaveTo: "-translate-y-4 opacity-0",
enter: `transition-[transform,opacity] ${duration}`,
enterFrom: "opacity-0 -translate-y-4",
enterTo: "translate-y-0 opacity-100",
};
}
if (animation === "slide-up") {
return {
leave: `transition-[transform,opacity] ${duration}`,
leaveFrom: "opacity-100 translate-y-0",
leaveTo: "translate-y-4 opacity-0",
enter: `transition-[transform,opacity] ${duration}`,
enterFrom: "opacity-0 translate-y-4",
enterTo: "translate-y-0 opacity-100",
};
}
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}`,
leaveFrom: "opacity-100",
leaveTo: "opacity-0",
enter: `transition-[transform,opacity] ${duration}`,
enterFrom: "opacity-0",
enterTo: "opacity-100",
};
}
return {};
}
export function Transition(props: Props) {
const duration = props.durationClass ?? "duration-200";
const classes = getClasses(props.animation, duration);
if (props.isChild) {
return (
<HeadlessTransition.Child as={Fragment} {...classes}>
<div className={props.className}>{props.children}</div>
</HeadlessTransition.Child>
);
}
return (
<HeadlessTransition show={props.show} as={Fragment} {...classes}>
<div className={props.className}>{props.children}</div>
</HeadlessTransition>
);
}

View File

@@ -6,7 +6,7 @@ import React, {
} from "react"; } from "react";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { Backdrop, useBackdrop } from "@/components/layout/Backdrop"; import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
import { ButtonControlProps, ButtonControl } from "./ButtonControl"; import { ButtonControlProps, ButtonControl } from "./ButtonControl";
export interface OptionItem { export interface OptionItem {
@@ -56,7 +56,7 @@ export const DropdownButton = React.forwardRef<
); );
useEffect(() => { useEffect(() => {
let id: NodeJS.Timeout; let id: ReturnType<typeof setTimeout>;
if (props.open) { if (props.open) {
setDelayedSelectedId(props.selectedItem); setDelayedSelectedId(props.selectedItem);
@@ -93,37 +93,43 @@ export const DropdownButton = React.forwardRef<
className="relative w-full sm:w-auto" className="relative w-full sm:w-auto"
{...highlightedProps} {...highlightedProps}
> >
<ButtonControl <BackdropContainer
{...props} onClick={() => props.setOpen(false)}
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-200 px-4 py-2 text-white hover:bg-bink-300" {...backdropProps}
> >
<Icon icon={selectedItem.icon} /> <ButtonControl
<span className="flex-1">{selectedItem.name}</span> {...props}
<Icon className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-400 px-4 py-2 text-white hover:bg-bink-300"
icon={Icons.CHEVRON_DOWN} >
className={`transition-transform ${props.open ? "rotate-180" : ""}`} <Icon icon={selectedItem.icon} />
/> <span className="flex-1">{selectedItem.name}</span>
</ButtonControl> <Icon
<div icon={Icons.CHEVRON_DOWN}
className={`absolute top-0 z-10 w-full rounded-[20px] bg-denim-300 pt-[40px] transition-all duration-200 ${ className={`transition-transform ${
props.open props.open ? "rotate-180" : ""
? "block max-h-60 opacity-100" }`}
: "invisible max-h-0 opacity-0" />
}`} </ButtonControl>
> <div
{props.options className={`absolute top-0 z-10 w-full rounded-[20px] bg-denim-300 pt-[40px] transition-all duration-200 ${
.filter((opt) => opt.id !== delayedSelectedId) props.open
.map((opt) => ( ? "block max-h-60 opacity-100"
<Option : "invisible max-h-0 opacity-0"
option={opt} }`}
key={opt.id} >
onClick={(e) => onOptionClick(e, opt)} {props.options
tabIndex={props.open ? 0 : undefined} .filter((opt) => opt.id !== delayedSelectedId)
/> .map((opt) => (
))} <Option
</div> option={opt}
key={opt.id}
onClick={(e) => onOptionClick(e, opt)}
tabIndex={props.open ? 0 : undefined}
/>
))}
</div>
</BackdropContainer>
</div> </div>
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} />
</div> </div>
); );
}); });

View File

@@ -0,0 +1,36 @@
import { Icon, Icons } from "@/components/Icon";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ButtonControl } from "./ButtonControl";
export interface EditButtonProps {
editing: boolean;
onEdit?: (editing: boolean) => void;
}
export function EditButton(props: EditButtonProps) {
const { t } = useTranslation();
const [parent] = useAutoAnimate<HTMLSpanElement>();
const onClick = useCallback(() => {
props.onEdit?.(!props.editing);
}, [props]);
return (
<ButtonControl
onClick={onClick}
className="flex h-12 items-center overflow-hidden rounded-full bg-denim-400 px-4 py-2 text-white transition-[background-color,transform] hover:bg-denim-500 active:scale-105"
>
<span ref={parent}>
{props.editing ? (
<span className="mx-4 whitespace-nowrap">
{t("media.stopEditing")}
</span>
) : (
<Icon icon={Icons.EDIT} />
)}
</span>
</ButtonControl>
);
}

View File

@@ -6,17 +6,24 @@ export interface IconPatchProps {
clickable?: boolean; clickable?: boolean;
className?: string; className?: string;
icon: Icons; icon: Icons;
transparent?: boolean;
} }
export function IconPatch(props: IconPatchProps) { export function IconPatch(props: IconPatchProps) {
const clickableClasses = props.clickable
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
: "";
const transparentClasses = props.transparent
? "bg-opacity-0 hover:bg-opacity-50"
: "";
const activeClasses = props.active
? "border-bink-600 bg-bink-100 text-bink-600"
: "";
return ( return (
<div className={props.className || undefined} onClick={props.onClick}> <div className={props.className || undefined} onClick={props.onClick}>
<div <div
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-300 transition-[color,transform,border-color] duration-75 ${ className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-500 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses}`}
props.clickable
? "cursor-pointer hover:scale-110 hover:bg-denim-400 hover:text-white active:scale-125"
: ""
} ${props.active ? "border-bink-600 bg-bink-100 text-bink-600" : ""}`}
> >
<Icon icon={props.icon} /> <Icon icon={props.icon} />
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import React, { createRef, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useFade } from "@/hooks/useFade"; import { useFade } from "@/hooks/useFade";
interface BackdropProps { interface BackdropProps {
@@ -39,7 +40,7 @@ export function useBackdrop(): [
return [setBackdrop, backdropProps, highlightedProps]; return [setBackdrop, backdropProps, highlightedProps];
} }
export function Backdrop(props: BackdropProps) { function Backdrop(props: BackdropProps) {
const clickEvent = props.onClick || (() => {}); const clickEvent = props.onClick || (() => {});
const animationEvent = props.onBackdropHide || (() => {}); const animationEvent = props.onBackdropHide || (() => {});
const [isVisible, setVisible, fadeProps] = useFade(); const [isVisible, setVisible, fadeProps] = useFade();
@@ -58,7 +59,7 @@ export function Backdrop(props: BackdropProps) {
return ( return (
<div <div
className={`fixed top-0 left-0 right-0 z-[999] h-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${ className={`pointer-events-auto fixed left-0 right-0 top-0 h-screen w-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
!isVisible ? "opacity-0" : "" !isVisible ? "opacity-0" : ""
}`} }`}
{...fadeProps} {...fadeProps}
@@ -66,3 +67,47 @@ export function Backdrop(props: BackdropProps) {
/> />
); );
} }
export function BackdropContainer(
props: {
children: React.ReactNode;
} & BackdropProps
) {
const root = createRef<HTMLDivElement>();
const copy = createRef<HTMLDivElement>();
useEffect(() => {
let frame = -1;
function poll() {
if (root.current && copy.current) {
const rect = root.current.getBoundingClientRect();
copy.current.style.top = `${rect.top}px`;
copy.current.style.left = `${rect.left}px`;
copy.current.style.width = `${rect.width}px`;
copy.current.style.height = `${rect.height}px`;
}
frame = window.requestAnimationFrame(poll);
}
poll();
return () => {
window.cancelAnimationFrame(frame);
};
// we dont want this to run only on mount, dont care about ref updates
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [root, copy]);
return (
<div ref={root}>
{createPortal(
<div className="pointer-events-none fixed top-0 left-0 z-[999]">
<Backdrop active={props.active} {...props} />
<div ref={copy} className="pointer-events-auto absolute">
{props.children}
</div>
</div>,
document.body
)}
<div className="invisible">{props.children}</div>
</div>
);
}

View File

@@ -1,18 +1,29 @@
import { Icon, Icons } from "@/components/Icon";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
export function BrandPill(props: { clickable?: boolean }) { export function BrandPill(props: {
clickable?: boolean;
hideTextOnMobile?: boolean;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div
className={`flex items-center space-x-2 rounded-full bg-bink-100 bg-opacity-50 px-4 py-2 text-bink-600 ${props.clickable className={`flex items-center space-x-2 rounded-full bg-bink-300 bg-opacity-50 px-4 py-2 text-bink-600 ${
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-200 hover:text-bink-700 active:scale-95" props.clickable
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-400 hover:text-bink-700 active:scale-95"
: "" : ""
}`} }`}
> >
<Icon className="text-xl" icon={Icons.MOVIE_WEB} /> <Icon className="text-xl" icon={Icons.MOVIE_WEB} />
<span className="font-semibold text-white">{t('global.name')}</span> <span
className={[
"font-semibold text-white",
props.hideTextOnMobile ? "hidden sm:block" : "",
].join(" ")}
>
{t("global.name")}
</span>
</div> </div>
); );
} }

View File

@@ -3,7 +3,65 @@ import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { Link } from "@/components/text/Link"; import { Link } from "@/components/text/Link";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { DISCORD_LINK, GITHUB_LINK } from "@/mw_constants"; import { conf } from "@/setup/config";
import { Trans, useTranslation } from "react-i18next";
interface ErrorShowcaseProps {
error: {
name: string;
description: string;
path: string;
};
}
export function ErrorShowcase(props: ErrorShowcaseProps) {
return (
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
<p className="mb-1 break-words font-bold text-white">
{props.error.name} - {props.error.description}
</p>
<p className="break-words">{props.error.path}</p>
</div>
);
}
interface ErrorMessageProps {
error?: {
name: string;
description: string;
path: string;
};
localSize?: boolean;
children?: React.ReactNode;
}
export function ErrorMessage(props: ErrorMessageProps) {
const { t } = useTranslation();
return (
<div
className={`${
props.localSize ? "h-full" : "min-h-screen"
} flex w-full flex-col items-center justify-center px-4 py-12`}
>
<div className="flex flex-col items-center justify-start text-center">
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
<Title>{t("media.errors.genericTitle")}</Title>
{props.children ? (
<p className="my-6 max-w-lg">{props.children}</p>
) : (
<p className="my-6 max-w-lg">
<Trans i18nKey="media.errors.videoFailed">
<Link url={conf().DISCORD_LINK} newTab />
<Link url={conf().GITHUB_LINK} newTab />
</Trans>
</p>
)}
</div>
{props.error ? <ErrorShowcase error={props.error} /> : null}
</div>
);
}
interface ErrorBoundaryState { interface ErrorBoundaryState {
hasError: boolean; hasError: boolean;
@@ -50,33 +108,6 @@ export class ErrorBoundary extends Component<
render() { render() {
if (!this.state.hasError) return this.props.children as any; if (!this.state.hasError) return this.props.children as any;
return ( return <ErrorMessage error={this.state.error} />;
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12">
<div className="flex flex-col items-center justify-start text-center">
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
<Title>Whoops, it broke</Title>
<p className="my-6 max-w-lg">
The app encountered an error and wasn&apos;t able to recover, please
report it to the{" "}
<Link url={DISCORD_LINK} newTab>
Discord server
</Link>{" "}
or on{" "}
<Link url={GITHUB_LINK} newTab>
GitHub
</Link>
.
</p>
</div>
{this.state.error ? (
<div className="w-4xl mt-12 max-w-full rounded bg-denim-300 px-6 py-4">
<p className="mb-1 break-words font-bold text-white">
{this.state.error.name} - {this.state.error.description}
</p>
<p className="break-words">{this.state.error.path}</p>
</div>
) : null}
</div>
);
} }
} }

View File

@@ -8,10 +8,10 @@ export function Loading(props: LoadingProps) {
<div className={props.className}> <div className={props.className}>
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<div className="flex h-12 items-center justify-center"> <div className="flex h-12 items-center justify-center">
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full" /> <div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300" />
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:150ms]" /> <div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:150ms]" />
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:300ms]" /> <div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:300ms]" />
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:450ms]" /> <div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:450ms]" />
</div> </div>
{props.text && props.text.length ? ( {props.text && props.text.length ? (
<p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p> <p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p>

View File

@@ -0,0 +1,44 @@
import { Overlay } from "@/components/Overlay";
import { Transition } from "@/components/Transition";
import { ReactNode } from "react";
import { createPortal } from "react-dom";
interface Props {
show: boolean;
children?: ReactNode;
}
export function ModalFrame(props: Props) {
return (
<Transition
className="fixed inset-0 z-[9999]"
animation="none"
show={props.show}
>
<Overlay>
<Transition
isChild
className="flex h-full w-full items-center justify-center"
animation="slide-up"
>
{props.children}
</Transition>
</Overlay>
</Transition>
);
}
export function Modal(props: Props) {
return createPortal(
<ModalFrame show={props.show}>{props.children}</ModalFrame>,
document.body
);
}
export function ModalCard(props: { children?: ReactNode }) {
return (
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10">
{props.children}
</div>
);
}

View File

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

View File

@@ -1,14 +1,16 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
export interface PaperProps { export interface PaperProps {
children?: ReactNode, children?: ReactNode;
className?: string, className?: string;
} }
export function Paper(props: PaperProps) { export function Paper(props: PaperProps) {
return ( return (
<div className={`bg-denim-200 lg:rounded-xl px-4 sm:px-8 md:px-12 py-6 sm:py-8 md:py-12 ${props.className}`}> <div
className={`bg-denim-200 px-4 py-6 sm:px-8 sm:py-8 md:px-12 md:py-12 lg:rounded-xl ${props.className}`}
>
{props.children} {props.children}
</div> </div>
) );
} }

View File

@@ -0,0 +1,39 @@
interface Props {
className?: string;
radius?: number;
percentage: number;
backingRingClassname?: string;
}
export function ProgressRing(props: Props) {
const radius = props.radius ?? 40;
return (
<svg
className={`${props.className ?? ""} -rotate-90`}
viewBox="0 0 100 100"
>
<circle
className={`fill-transparent stroke-denim-700 stroke-[15] opacity-25 ${
props.backingRingClassname ?? ""
}`}
r={radius}
cx="50"
cy="50"
/>
<circle
className="fill-transparent stroke-current stroke-[15] transition-[stroke-dashoffset] duration-150"
r={radius}
cx="50"
cy="50"
style={{
strokeDasharray: `${2 * Math.PI * radius} ${2 * Math.PI * radius}`,
strokeDashoffset: `${
2 * Math.PI * radius -
(props.percentage / 100) * (2 * Math.PI * radius)
}`,
}}
/>
</svg>
);
}

View File

@@ -1,124 +0,0 @@
import { useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Dropdown, OptionItem } from "@/components/Dropdown";
import { Icons } from "@/components/Icon";
import { WatchedEpisode } from "@/components/media/WatchedEpisodeButton";
import { useLoading } from "@/hooks/useLoading";
import { serializePortableMedia } from "@/hooks/usePortableMedia";
import {
convertMediaToPortable,
MWMedia,
MWMediaSeasons,
MWMediaSeason,
MWPortableMedia,
} from "@/providers";
import { getSeasonDataFromMedia } from "@/providers/methods/seasons";
import { useTranslation } from "react-i18next";
export interface SeasonsProps {
media: MWMedia;
}
export function LoadingSeasons(props: { error?: boolean }) {
const { t } = useTranslation();
return (
<div>
<div>
<div className="mb-3 mt-5 h-10 w-56 rounded bg-denim-400 opacity-50" />
</div>
{!props.error ? (
<>
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" />
</>
) : (
<div className="flex items-center space-x-3">
<IconPatch icon={Icons.WARNING} className="text-red-400" />
<p>{t('seasons.failed')}</p>
</div>
)}
</div>
);
}
export function Seasons(props: SeasonsProps) {
const { t } = useTranslation();
const [searchSeasons, loading, error, success] = useLoading(
(portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia)
);
const history = useHistory();
const [seasons, setSeasons] = useState<MWMediaSeasons>({ seasons: [] });
const seasonSelected = props.media.seasonId as string;
const episodeSelected = props.media.episodeId as string;
useEffect(() => {
(async () => {
const seasonData = await searchSeasons(props.media);
setSeasons(seasonData);
})();
}, [searchSeasons, props.media]);
function navigateToSeasonAndEpisode(seasonId: string, episodeId: string) {
const newMedia: MWMedia = { ...props.media };
newMedia.episodeId = episodeId;
newMedia.seasonId = seasonId;
history.replace(
`/media/${newMedia.mediaType}/${serializePortableMedia(
convertMediaToPortable(newMedia)
)}`
);
}
const mapSeason = (season: MWMediaSeason) => ({
id: season.id,
name: season.title || `${t('seasons.season', { season: season.sort })}`,
});
const options = seasons.seasons.map(mapSeason);
const foundSeason = seasons.seasons.find(
(season) => season.id === seasonSelected
);
const selectedItem = foundSeason ? mapSeason(foundSeason) : null;
return (
<>
{loading ? <LoadingSeasons /> : null}
{error ? <LoadingSeasons error /> : null}
{success && seasons.seasons.length ? (
<>
<Dropdown
selectedItem={selectedItem as OptionItem}
options={options}
setSelectedItem={(seasonItem) =>
navigateToSeasonAndEpisode(
seasonItem.id,
seasons.seasons.find((s) => s.id === seasonItem.id)?.episodes[0]
.id as string
)
}
/>
{seasons.seasons
.find((s) => s.id === seasonSelected)
?.episodes.map((v) => (
<WatchedEpisode
key={v.id}
media={{
...props.media,
seriesData: seasons,
episodeId: v.id,
seasonId: seasonSelected,
}}
active={v.id === episodeSelected}
onClick={() => navigateToSeasonAndEpisode(seasonSelected, v.id)}
/>
))}
</>
) : null}
</>
);
}

View File

@@ -1,20 +1,17 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { ArrowLink } from "@/components/text/ArrowLink";
interface SectionHeadingProps { interface SectionHeadingProps {
icon?: Icons; icon?: Icons;
title: string; title: string;
children?: ReactNode; children?: ReactNode;
linkText?: string;
onClick?: () => void;
className?: string; className?: string;
} }
export function SectionHeading(props: SectionHeadingProps) { export function SectionHeading(props: SectionHeadingProps) {
return ( return (
<div className={`mt-12 ${props.className}`}> <div className={props.className}>
<div className="mb-4 flex items-end"> <div className="mb-5 flex items-center">
<p className="flex flex-1 items-center font-bold uppercase text-denim-700"> <p className="flex flex-1 items-center font-bold uppercase text-denim-700">
{props.icon ? ( {props.icon ? (
<span className="mr-2 text-xl"> <span className="mr-2 text-xl">
@@ -23,15 +20,8 @@ export function SectionHeading(props: SectionHeadingProps) {
) : null} ) : null}
{props.title} {props.title}
</p> </p>
{props.linkText ? ( {props.children}
<ArrowLink
linkText={props.linkText}
direction="left"
onClick={props.onClick}
/>
) : null}
</div> </div>
{props.children}
</div> </div>
); );
} }

View File

@@ -0,0 +1,20 @@
.spinner {
font-size: 48px;
width: 1em;
height: 1em;
border: 0.12em solid var(--color,white);
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: spinner-rotation 800ms linear infinite;
}
@keyframes spinner-rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,9 @@
import "./Spinner.css";
interface SpinnerProps {
className?: string;
}
export function Spinner(props: SpinnerProps) {
return <div className={["spinner", props.className ?? ""].join(" ")} />;
}

View File

@@ -8,7 +8,9 @@ interface ThinContainerProps {
export function ThinContainer(props: ThinContainerProps) { export function ThinContainer(props: ThinContainerProps) {
return ( return (
<div <div
className={`max-w-[600px] mx-auto px-2 sm:px-0 ${props.classNames || ""}`} className={`mx-auto w-[600px] max-w-full px-2 sm:px-0 ${
props.classNames || ""
}`}
> >
{props.children} {props.children}
</div> </div>

View File

@@ -0,0 +1,18 @@
import { ReactNode } from "react";
interface WideContainerProps {
classNames?: string;
children?: ReactNode;
}
export function WideContainer(props: WideContainerProps) {
return (
<div
className={`mx-auto w-[700px] max-w-full px-8 sm:px-4 ${
props.classNames || ""
}`}
>
{props.children}
</div>
);
}

View File

@@ -9,12 +9,12 @@ export function Episode(props: EpisodeProps) {
return ( return (
<div <div
onClick={props.onClick} onClick={props.onClick}
className={`bg-denim-500 hover:bg-denim-400 transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded font-bold text-white active:scale-110 ${ className={`transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
props.active ? "shadow-bink-500 shadow-[inset_0_0_0_2px]" : "" props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : ""
}`} }`}
> >
<div <div
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50" className="absolute bottom-0 top-0 left-0 bg-bink-500 bg-opacity-50"
style={{ style={{
width: `${props.progress || 0}%`, width: `${props.progress || 0}%`,
}} }}

View File

@@ -1,96 +1,145 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import { useTranslation } from "react-i18next";
convertMediaToPortable,
getProviderFromId,
MWMediaMeta,
MWMediaType,
} from "@/providers";
import { Icon, Icons } from "@/components/Icon";
import { serializePortableMedia } from "@/hooks/usePortableMedia";
import { DotList } from "@/components/text/DotList"; import { DotList } from "@/components/text/DotList";
import { MWMediaMeta } from "@/backend/metadata/types";
import { JWMediaToId } from "@/backend/metadata/justwatch";
import { Icons } from "../Icon";
import { IconPatch } from "../buttons/IconPatch";
export interface MediaCardProps { export interface MediaCardProps {
media: MWMediaMeta; media: MWMediaMeta;
watchedPercentage: number;
linkable?: boolean; linkable?: boolean;
series?: boolean; series?: {
episode: number;
season: number;
episodeId: string;
seasonId: string;
};
percentage?: number;
closable?: boolean;
onClose?: () => void;
} }
function MediaCardContent({ function MediaCardContent({
media, media,
linkable, linkable,
watchedPercentage,
series, series,
percentage,
closable,
onClose,
}: MediaCardProps) { }: MediaCardProps) {
const provider = getProviderFromId(media.providerId); const { t } = useTranslation();
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
if (!provider) { const canLink = linkable && !closable;
return null;
} const dotListContent = [t(`media.${media.type}`)];
if (media.year) dotListContent.push(media.year);
return ( return (
<article <div
className={`group relative mb-4 flex overflow-hidden rounded bg-denim-300 py-4 px-5 ${ className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
linkable ? "hover:bg-denim-400" : "" canLink ? "hover:bg-opacity-100" : ""
}`} }`}
> >
{/* progress background */} <article
{watchedPercentage > 0 ? ( className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
<div className="absolute top-0 left-0 right-0 bottom-0"> canLink ? "group-hover:scale-95" : ""
}`}
>
<div
className={[
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
closable ? "" : "group-hover:rounded-lg",
].join(" ")}
style={{
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
}}
>
{series ? (
<div
className={[
"absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors",
closable ? "" : "group-hover:bg-denim-500",
].join(" ")}
>
<p
className={[
"text-center text-xs font-bold text-slate-400 transition-colors",
closable ? "" : "group-hover:text-white",
].join(" ")}
>
{t("seasons.seasonAndEpisode", {
season: series.season,
episode: series.episode,
})}
</p>
</div>
) : null}
{percentage !== undefined ? (
<>
<div
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
canLink ? "group-hover:from-denim-100" : ""
}`}
/>
<div
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
canLink ? "group-hover:from-denim-100" : ""
}`}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
<div
className="absolute inset-y-0 left-0 rounded-full bg-bink-700"
style={{
width: percentageString,
}}
/>
</div>
</div>
</>
) : null}
<div <div
className="relative h-full bg-bink-300 bg-opacity-30" className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
style={{ closable ? "opacity-100" : "pointer-events-none opacity-0"
width: `${watchedPercentage}%`, }`}
}}
> >
<div className="absolute right-0 top-0 bottom-0 ml-auto w-40 bg-gradient-to-l from-bink-400 to-transparent opacity-40" /> <IconPatch
clickable
className="text-2xl text-slate-400"
onClick={() => closable && onClose?.()}
icon={Icons.X}
/>
</div> </div>
</div> </div>
) : null} <h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
<span>{media.title}</span>
<div className="relative flex flex-1"> </h1>
{/* card content */} <DotList className="text-xs" content={dotListContent} />
<div className="flex-1"> </article>
<h1 className="mb-1 font-bold text-white"> </div>
{media.title}
{series && media.seasonId && media.episodeId ? (
<span className="ml-2 text-xs text-denim-700">
S{media.seasonId} E{media.episodeId}
</span>
) : null}
</h1>
<DotList
className="text-xs"
content={[provider.displayName, media.mediaType, media.year]}
/>
</div>
{/* hoverable chevron */}
<div
className={`flex translate-x-3 items-center justify-end text-xl text-white opacity-0 transition-[opacity,transform] ${
linkable ? "group-hover:translate-x-0 group-hover:opacity-100" : ""
}`}
>
<Icon icon={Icons.CHEVRON_RIGHT} />
</div>
</div>
</article>
); );
} }
export function MediaCard(props: MediaCardProps) { export function MediaCard(props: MediaCardProps) {
let link = "movie";
if (props.media.mediaType === MWMediaType.SERIES) link = "series";
const content = <MediaCardContent {...props} />; const content = <MediaCardContent {...props} />;
const canLink = props.linkable && !props.closable;
let link = canLink
? `/media/${encodeURIComponent(JWMediaToId(props.media))}`
: "#";
if (canLink && props.series)
link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent(
props.series.episodeId
)}`;
if (!props.linkable) return <span>{content}</span>; if (!props.linkable) return <span>{content}</span>;
return ( return (
<Link <Link to={link} className={props.closable ? "hover:cursor-default" : ""}>
to={`/media/${link}/${serializePortableMedia(
convertMediaToPortable(props.media)
)}`}
>
{content} {content}
</Link> </Link>
); );

View File

@@ -0,0 +1,15 @@
import { forwardRef } from "react";
interface MediaGridProps {
children?: React.ReactNode;
}
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
(props, ref) => {
return (
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
{props.children}
</div>
);
}
);

View File

@@ -1,109 +0,0 @@
import { ReactElement, useEffect, useRef, useState } from "react";
import Hls from "hls.js";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Loading } from "@/components/layout/Loading";
import { MWMediaCaption, MWMediaStream } from "@/providers";
export interface VideoPlayerProps {
source: MWMediaStream;
captions: MWMediaCaption[];
startAt?: number;
onProgress?: (event: ProgressEvent) => void;
}
export function SkeletonVideoPlayer(props: { error?: boolean }) {
return (
<div className="flex aspect-video w-full items-center justify-center bg-denim-200 lg:rounded-xl">
{props.error ? (
<div className="flex flex-col items-center">
<IconPatch icon={Icons.WARNING} className="text-red-400" />
<p className="mt-5 text-white">Couldn&apos;t get your stream</p>
</div>
) : (
<div className="flex flex-col items-center">
<Loading />
<p className="mt-3 text-white">Getting your stream...</p>
</div>
)}
</div>
);
}
export function VideoPlayer(props: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [hasErrored, setErrored] = useState(false);
const [isLoading, setLoading] = useState(true);
const showVideo = !isLoading && !hasErrored;
const mustUseHls = props.source.type === "m3u8";
// reset if stream url changes
useEffect(() => {
setLoading(true);
setErrored(false);
// hls support
if (mustUseHls) {
if (!videoRef.current) return;
if (!Hls.isSupported()) {
setLoading(false);
setErrored(true);
return;
}
const hls = new Hls();
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
videoRef.current.src = props.source.url;
return;
}
hls.attachMedia(videoRef.current);
hls.loadSource(props.source.url);
hls.on(Hls.Events.ERROR, (event, data) => {
setErrored(true);
console.error(data);
});
}
}, [props.source.url, videoRef, mustUseHls]);
let skeletonUi: null | ReactElement = null;
if (hasErrored) {
skeletonUi = <SkeletonVideoPlayer error />;
} else if (isLoading) {
skeletonUi = <SkeletonVideoPlayer />;
}
return (
<>
{skeletonUi}
<video
className={`w-full rounded-xl bg-black ${!showVideo ? "hidden" : ""}`}
ref={videoRef}
onProgress={(e) =>
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
}
onLoadedData={(e) => {
setLoading(false);
if (props.startAt)
(e.target as HTMLVideoElement).currentTime = props.startAt;
}}
onError={(e) => {
console.error("failed to playback stream", e);
setErrored(true);
}}
controls
autoPlay
>
{!mustUseHls ? (
<source src={props.source.url} type="video/mp4" />
) : null}
{props.captions.map((v) => (
<track key={v.id} kind="captions" label={v.label} src={v.url} />
))}
</video>
</>
);
}

View File

@@ -1,25 +0,0 @@
import { getEpisodeFromMedia, MWMedia } from "@/providers";
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched";
import { Episode } from "./EpisodeButton";
export interface WatchedEpisodeProps {
media: MWMedia;
onClick?: () => void;
active?: boolean;
}
export function WatchedEpisode(props: WatchedEpisodeProps) {
const { watched } = useWatchedContext();
const foundWatched = getWatchedFromPortable(watched.items, props.media);
const episode = getEpisodeFromMedia(props.media);
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
return (
<Episode
progress={watchedPercentage}
episodeNumber={episode?.episode?.sort ?? 1}
active={props.active}
onClick={props.onClick}
/>
);
}

View File

@@ -1,23 +1,44 @@
import { MWMediaMeta } from "@/providers"; import { MWMediaMeta } from "@/backend/metadata/types";
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched"; import { useWatchedContext } from "@/state/watched";
import { useMemo } from "react";
import { MediaCard } from "./MediaCard"; import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps { export interface WatchedMediaCardProps {
media: MWMediaMeta; media: MWMediaMeta;
series?: boolean; closable?: boolean;
onClose?: () => void;
}
function formatSeries(
obj:
| { episodeId: string; seasonId: string; episode: number; season: number }
| undefined
) {
if (!obj) return undefined;
return {
season: obj.season,
episode: obj.episode,
episodeId: obj.episodeId,
seasonId: obj.seasonId,
};
} }
export function WatchedMediaCard(props: WatchedMediaCardProps) { export function WatchedMediaCard(props: WatchedMediaCardProps) {
const { watched } = useWatchedContext(); const { watched } = useWatchedContext();
const foundWatched = getWatchedFromPortable(watched.items, props.media); const watchedMedia = useMemo(() => {
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0; return watched.items
.sort((a, b) => b.watchedAt - a.watchedAt)
.find((v) => v.item.meta.id === props.media.id);
}, [watched, props.media]);
return ( return (
<MediaCard <MediaCard
watchedPercentage={watchedPercentage}
media={props.media} media={props.media}
series={props.series && props.media.episodeId !== undefined} series={formatSeries(watchedMedia?.item?.series)}
linkable linkable
percentage={watchedMedia?.percentage}
onClose={props.onClose}
closable={props.closable}
/> />
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ export interface DotListProps {
export function DotList(props: DotListProps) { export function DotList(props: DotListProps) {
return ( return (
<p className={`text-denim-700 font-semibold ${props.className || ""}`}> <p className={`font-semibold text-denim-700 ${props.className || ""}`}>
{props.content.map((item, index) => ( {props.content.map((item, index) => (
<span key={item}> <span key={item}>
{index !== 0 ? ( {index !== 0 ? (

View File

@@ -16,22 +16,27 @@ interface ILinkPropsInternal extends ILinkPropsBase {
to: string; to: string;
} }
type LinkProps = type LinkProps = ILinkPropsExternal | ILinkPropsInternal | ILinkPropsBase;
| ILinkPropsExternal
| ILinkPropsInternal
| ILinkPropsBase;
export function Link(props: LinkProps) { export function Link(props: LinkProps) {
const isExternal = !!(props as ILinkPropsExternal).url; const isExternal = !!(props as ILinkPropsExternal).url;
const isInternal = !!(props as ILinkPropsInternal).to; const isInternal = !!(props as ILinkPropsInternal).to;
const content = ( const content = (
<span className="text-bink-600 hover:text-bink-700 cursor-pointer font-bold"> <span className="cursor-pointer font-bold text-bink-600 hover:text-bink-700">
{props.children} {props.children}
</span> </span>
); );
if (isExternal) if (isExternal)
return <a target={(props as ILinkPropsExternal).newTab ? "_blank" : undefined} rel="noreferrer" href={(props as ILinkPropsExternal).url}>{content}</a>; return (
<a
target={(props as ILinkPropsExternal).newTab ? "_blank" : undefined}
rel="noreferrer"
href={(props as ILinkPropsExternal).url}
>
{content}
</a>
);
if (isInternal) if (isInternal)
return ( return (
<LinkRouter to={(props as ILinkPropsInternal).to}>{content}</LinkRouter> <LinkRouter to={(props as ILinkPropsInternal).to}>{content}</LinkRouter>

View File

@@ -1,7 +0,0 @@
export interface TaglineProps {
children?: React.ReactNode;
}
export function Tagline(props: TaglineProps) {
return <p className="font-bold text-bink-600">{props.children}</p>;
}

View File

@@ -1,7 +1,16 @@
export interface TitleProps { export interface TitleProps {
children?: React.ReactNode; children?: React.ReactNode;
className?: string;
} }
export function Title(props: TitleProps) { export function Title(props: TitleProps) {
return <h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white">{props.children}</h1>; return (
<h1
className={`text-2xl font-bold text-white sm:text-3xl md:text-4xl ${
props.className ?? ""
}`}
>
{props.children}
</h1>
);
} }

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

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

View File

@@ -0,0 +1,110 @@
/// <reference types="chromecast-caf-sender"/>
import { isChromecastAvailable } from "@/setup/chromecast";
import { useEffect, useRef, useState } from "react";
export function useChromecastAvailable() {
const [available, setAvailable] = useState<boolean | null>(null);
useEffect(() => {
isChromecastAvailable((bool) => setAvailable(bool));
}, []);
return available;
}
export function useChromecast() {
const available = useChromecastAvailable();
const instance = useRef<cast.framework.CastContext | null>(null);
const remotePlayerController =
useRef<cast.framework.RemotePlayerController | null>(null);
function startCast() {
const movieMeta = new chrome.cast.media.MovieMediaMetadata();
movieMeta.title = "Big Buck Bunny";
const mediaInfo = new chrome.cast.media.MediaInfo("hello", "video/mp4");
(mediaInfo as any).contentUrl =
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
mediaInfo.metadata = movieMeta;
const request = new chrome.cast.media.LoadRequest(mediaInfo);
request.autoplay = true;
const session = instance.current?.getCurrentSession();
console.log("testing", session);
if (!session) return;
session
.loadMedia(request)
.then(() => {
console.log("Media is loaded");
})
.catch((e: any) => {
console.error(e);
});
}
function stopCast() {
const session = instance.current?.getCurrentSession();
if (!session) return;
const controller = remotePlayerController.current;
if (!controller) return;
controller.stop();
}
useEffect(() => {
if (!available) return;
// setup instance if not already
if (!instance.current) {
const ins = cast.framework.CastContext.getInstance();
ins.setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
instance.current = ins;
}
// setup player if not already
if (!remotePlayerController.current) {
const player = new cast.framework.RemotePlayer();
const controller = new cast.framework.RemotePlayerController(player);
remotePlayerController.current = controller;
}
// setup event listener
function listenToEvents(e: cast.framework.RemotePlayerChangedEvent) {
console.log("chromecast event", e);
}
function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) {
console.log("chromecast event connection changed", e);
}
remotePlayerController.current.addEventListener(
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
listenToEvents
);
remotePlayerController.current.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
connectionChanged
);
return () => {
remotePlayerController.current?.removeEventListener(
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
listenToEvents
);
remotePlayerController.current?.removeEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
connectionChanged
);
};
}, [available]);
return {
startCast,
stopCast,
};
}

View File

@@ -4,17 +4,14 @@ export function useDebounce<T>(value: T, delay: number): T {
// State and setters for debounced value // State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState<T>(value); const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect( useEffect(() => {
() => { const handler = setTimeout(() => {
const handler = setTimeout(() => { setDebouncedValue(value);
setDebouncedValue(value); }, delay);
}, delay); return () => {
return () => { clearTimeout(handler);
clearTimeout(handler); };
}; }, [value, delay]);
},
[value, delay]
);
return debouncedValue; return debouncedValue;
} }

View File

@@ -1,7 +1,9 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import './useFade.css' import "./useFade.css";
export const useFade = (initial = false): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => { export const useFade = (
initial = false
): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => {
const [show, setShow] = useState<boolean>(initial); const [show, setShow] = useState<boolean>(initial);
const [isVisible, setVisible] = useState<boolean>(show); const [isVisible, setVisible] = useState<boolean>(show);
@@ -20,7 +22,7 @@ export const useFade = (initial = false): [boolean, React.Dispatch<React.SetStat
// These props go on the fading DOM element // These props go on the fading DOM element
const fadeProps = { const fadeProps = {
style, style,
onAnimationEnd onAnimationEnd,
}; };
return [isVisible, setShow, fadeProps]; return [isVisible, setShow, fadeProps];

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

12
src/hooks/useGoBack.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useCallback } from "react";
import { useHistory } from "react-router-dom";
export function useGoBack() {
const reactHistory = useHistory();
const goBack = useCallback(() => {
if (reactHistory.action !== "POP") reactHistory.goBack();
else reactHistory.push("/");
}, [reactHistory]);
return goBack;
}

30
src/hooks/useIsMobile.ts Normal file
View File

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

View File

@@ -2,7 +2,12 @@ import React, { useMemo, useRef, useState } from "react";
export function useLoading<T extends (...args: any) => Promise<any>>( export function useLoading<T extends (...args: any) => Promise<any>>(
action: T action: T
) { ): [
(...args: Parameters<T>) => ReturnType<T> | Promise<undefined>,
boolean,
Error | undefined,
boolean
] {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [error, setError] = useState<any | undefined>(undefined); const [error, setError] = useState<any | undefined>(undefined);
@@ -20,11 +25,11 @@ export function useLoading<T extends (...args: any) => Promise<any>>(
const doAction = useMemo( const doAction = useMemo(
() => () =>
async (...args: Parameters<T>) => { async (...args: any) => {
setLoading(true); setLoading(true);
setSuccess(false); setSuccess(false);
setError(undefined); setError(undefined);
return new Promise((resolve) => { return new Promise<any>((resolve) => {
actionMemo(...args) actionMemo(...args)
.then((v) => { .then((v) => {
if (!isMounted.current) return resolve(undefined); if (!isMounted.current) return resolve(undefined);
@@ -35,6 +40,7 @@ export function useLoading<T extends (...args: any) => Promise<any>>(
.catch((err) => { .catch((err) => {
if (isMounted) { if (isMounted) {
setError(err); setError(err);
console.error("USELOADING ERROR", err);
setSuccess(false); setSuccess(false);
} }
resolve(undefined); resolve(undefined);

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

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

View File

@@ -1,30 +0,0 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { MWPortableMedia } from "@/providers";
export function deserializePortableMedia(media: string): MWPortableMedia {
return JSON.parse(atob(decodeURIComponent(media)));
}
export function serializePortableMedia(media: MWPortableMedia): string {
const data = encodeURIComponent(btoa(JSON.stringify(media)));
return data;
}
export function usePortableMedia(): MWPortableMedia | undefined {
const { media } = useParams<{ media: string }>();
const [mediaObject, setMediaObject] = useState<MWPortableMedia | undefined>(
undefined
);
useEffect(() => {
try {
setMediaObject(deserializePortableMedia(media));
} catch (err) {
console.error("Failed to deserialize portable media", err);
setMediaObject(undefined);
}
}, [media, setMediaObject]);
return mediaObject;
}

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