Compare commits

..

550 Commits

Author SHA1 Message Date
Adrian Castro
ef97313fb9 fix: cast this value although it's fine 2024-04-20 15:27:25 +02:00
Adrian Castro
932dcddfc0 chore: format 2024-04-20 15:23:31 +02:00
Adrian Castro
ac4e5cc6bd fix: temporarily pin openssl pod until new quick crypto release 2024-04-20 03:10:51 +02:00
Jorrin
0820e5b7c7 Update package.json 2024-04-20 00:57:54 +02:00
Jorrin
1e7f3b9dc0 update pnpm to v9 2024-04-20 00:56:12 +02:00
Jorrin
59f27b0397 update node in action 2024-04-20 00:54:06 +02:00
Jorrin
c1e3d91d84 that didnt work 2024-04-20 00:50:03 +02:00
Jorrin
aeeb34db0f weird typescript thing 2024-04-20 00:45:56 +02:00
Jorrin
61076b344f Update tsconfig.json 2024-04-20 00:43:09 +02:00
Jorrin
9eb9fb494c Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-04-20 00:11:03 +02:00
Jorrin
bbeb729156 add register and login screens 2024-04-20 00:11:02 +02:00
Adrian Castro
b530284519 chore: clean redundant await expressions 2024-04-19 22:56:54 +02:00
Jorrin
fcfd0d99cc Update pnpm-lock.yaml 2024-04-19 20:42:52 +02:00
Jorrin
75f5256b20 remove ofetch, replace with fetch 2024-04-19 20:41:21 +02:00
Adrian Castro
eea4eab60b feat: api hooks n stuff 2024-04-19 19:19:59 +02:00
Adrian Castro
3fb2567ae1 feat: finish api package 2024-04-18 17:34:40 +02:00
Adrian Castro
4f833bee46 chore: formatting 2024-04-15 21:19:46 +02:00
Adrian Castro
338e633d48 feat: additional api package stuff 2024-04-15 21:18:25 +02:00
Adrian Castro
4e01f35458 feat: auth store 2024-04-15 20:49:21 +02:00
Adrian Castro
e8dfb5eaf4 feat: auth functions 2024-04-15 20:15:57 +02:00
Jorrin
07d313b1fd start with movie-web page 2024-04-15 19:34:42 +02:00
Adrian Castro
0622e4338c feat: go back to downloads tab if all episodes removed 2024-04-15 03:04:32 +02:00
Adrian Castro
097296fcfa chore: Update renovate.json 2024-04-15 00:22:37 +02:00
Adrian Castro
861a5a8eb9 chore: move renovate config to root 2024-04-15 00:18:57 +02:00
Adrian Castro
7b17b2c103 fix: use localuri on ios 2024-04-13 22:38:29 +02:00
Jorrin
5def4e8461 fix saved local path 2024-04-13 22:12:29 +02:00
Jorrin
8d1ec8f1dc fix DownloadItem show title row styling 2024-04-13 21:56:20 +02:00
Jorrin
e83054c1ca fix missing / on cache directory 2024-04-13 21:25:52 +02:00
Adrian Castro
93111ecdcd fix: a bunch of idiotism 2024-04-13 21:16:03 +02:00
Jorrin
17d907335f Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-04-13 21:03:25 +02:00
Jorrin
030dca29f9 header padding 2024-04-13 21:03:24 +02:00
Adrian Castro
2b1aa407d4 fix: some nonsense 2024-04-13 20:54:24 +02:00
Jorrin
5b80273dfb Fix for infinite rerender while scraping 🍻 2024-04-13 20:24:59 +02:00
Adrian Castro
4a3d363bf2 feat: implement default quality setting 2024-04-11 20:53:53 +02:00
Adrian Castro
45d12bbf41 refactor: use expo filsesystem for downloads 2024-04-08 22:26:37 +02:00
Jorrin
96b00064c6 add episode download section 2024-04-08 21:22:09 +02:00
Adrian Castro
ae5505da7f feat: allow itemdata to hold season and episode numbers 2024-04-08 18:36:44 +02:00
Adrian Castro
8b7bf5da6d fix: adjust bar height over keyboard 2024-04-07 21:46:05 +02:00
Adrian Castro
a5ab7f4767 fix: grab local uri from assetinfo 2024-04-07 19:53:09 +02:00
Adrian Castro
1ab4b7cec5 fix: searchbar actually shows up again 2024-04-07 19:35:26 +02:00
Jorrin
8f5d0247bb rework downloads 2024-04-06 22:27:55 +02:00
Jorrin
b2f1782311 flash text while its in progress 2024-04-06 17:30:22 +02:00
Jorrin
1a142548eb fix show title format 2024-04-06 17:00:51 +02:00
Jorrin
c61f18941e downloads refactor 2024-04-06 16:53:54 +02:00
Adrian Castro
bf6bd7af2f feat: disallow downloads on mobile data if disabled 2024-04-04 20:12:46 +02:00
Adrian Castro
05a09cc6cd feat: add settings for default quality on wifi/data 2024-04-04 19:58:31 +02:00
Adrian Castro
899d599036 fix: properly support opening video via url 2024-04-04 19:25:04 +02:00
Adrian Castro
36b24aba5c feat: clear cache button 2024-04-02 22:40:30 +02:00
Adrian Castro
4a1b1305b5 chore: bump providers cuz they unbroke 2024-04-02 22:11:28 +02:00
Adrian Castro
6b271ad464 chore: downgrade providers cuz they broke 2024-04-02 22:06:41 +02:00
Jorrin
a8c90a01ed Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-04-02 22:01:50 +02:00
Jorrin
6c55ed92e2 moved playback and quality in one settings menu 2024-04-02 22:01:48 +02:00
Adrian Castro
9273a32f17 chore: cleanup n stuffs 2024-04-02 21:58:28 +02:00
Jorrin
4eaf04761e switch animation 2024-04-02 17:38:16 +02:00
Jorrin
71025ec645 decrease font size in settings 2024-04-02 17:33:53 +02:00
Jorrin
6718730c82 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-04-02 17:26:57 +02:00
Jorrin
c1d6a4ddda cleanup 2024-04-02 17:26:55 +02:00
Jorrin
925b28019f improved settings design 2024-04-02 17:25:24 +02:00
Adrian Castro
4cfc4fc127 chore: ffs 2024-04-02 15:55:43 +02:00
Adrian Castro
e0cb7ea920 fix: tsconfig 2024-04-02 15:53:20 +02:00
Adrian Castro
bd285e304b fix: probably make the module ios only as it's intended to be 2024-04-02 15:50:17 +02:00
Jorrin
9dc973dd38 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-04-02 15:38:38 +02:00
Jorrin
471be3b551 Fix year NaN, no longer open unreleased 2024-04-02 15:38:21 +02:00
Adrian Castro
657c2d01a0 chore: js function name in kt module 2024-04-02 15:27:04 +02:00
Adrian Castro
44647a4141 chore: keep android native code in native module 2024-04-02 15:23:52 +02:00
Adrian Castro
df4fe312fc feat: play video via url 2024-04-02 08:51:00 +02:00
Adrian Castro
21169c6caa chore: that probably works 2024-04-02 02:53:58 +02:00
Adrian Castro
1e6e3ea9ea chore: come on 2024-04-02 02:43:35 +02:00
Adrian Castro
21b2dfaf94 fix: perhaps fix compile error 2024-04-02 02:26:52 +02:00
Adrian Castro
f272187ba4 fix: remove native ios modal and set insets on modal content instead of frame #bigbrainmoment 2024-04-02 01:48:55 +02:00
Adrian Castro
07b9f7cd4b fix: use zstack to keep searchbar visible on search screen 2024-04-02 01:25:59 +02:00
Adrian Castro
6a4a19a41c chore: cleanup 2024-04-02 01:17:43 +02:00
Adrian Castro
91301991c4 chore: cleanup 2024-04-02 00:23:05 +02:00
Adrian Castro
e45a668c38 feat: use svg in brandpill (might need some adjusting, but looks fine) 2024-04-02 00:21:53 +02:00
Adrian Castro
32ce520fc0 chore: format 2024-04-01 23:53:12 +02:00
Adrian Castro
7b1dd8170d feat: gate download behind development certificate on iOS 2024-04-01 23:44:59 +02:00
Adrian Castro
683cab9796 fix: use safe insets for header 2024-04-01 22:50:41 +02:00
Jorrin
35997d178d settings cleanup 2024-04-01 22:04:14 +02:00
Jorrin
908da0bd24 add header and background design 2024-04-01 21:59:03 +02:00
Adrian Castro
9ace6afc9e chore: bleh 2024-04-01 18:26:11 +02:00
Adrian Castro
30e52c2b72 chore: url parsing stuff for later when I have time 2024-04-01 18:20:56 +02:00
Adrian Castro
2399926cbc chore: add run config 2024-03-29 12:15:06 +01:00
Adrian Castro
102dbc6f9a chore: readme adjustment 2024-03-29 12:02:58 +01:00
Adrian Castro
8817171c86 feat: dependabot for providers 2024-03-28 18:10:45 +01:00
Adrian Castro
3b0a59c2c6 chore: fix app repo step 2024-03-28 17:51:23 +01:00
Adrian Castro
85cf3079bd chore: adjust workflow step 2024-03-28 16:39:22 +01:00
Adrian Castro
f7af613940 chore: adjust 2024-03-28 14:49:48 +01:00
Adrian Castro
79220ec8a0 chore: adjust app repo and workflow 2024-03-28 14:49:18 +01:00
Adrian Castro
0d730f0096 chore: Update README.md 2024-03-28 14:10:20 +01:00
Adrian Castro
c7f283abd2 fix: move perm request to download function 2024-03-28 01:40:47 +01:00
Adrian Castro
1e66bc0c57 chore: format 2024-03-28 01:14:16 +01:00
Adrian Castro
3f91edc5b0 chore: cleanup 2024-03-28 01:13:01 +01:00
Adrian Castro
d82f5a4573 refactor: make hls downloads also use background task 2024-03-28 01:07:11 +01:00
Adrian Castro
1f7e8f4d86 feat: background tasks ios setup 2024-03-28 00:40:06 +01:00
Adrian Castro
a709eb3f4c fix: download speed 2024-03-28 00:06:05 +01:00
Adrian Castro
57cd3e642b feat: background task for mp4 downloads 2024-03-27 23:57:02 +01:00
Adrian Castro
1c5a63f8f1 feat: background download plugin and dep 2024-03-27 19:49:45 +01:00
Adrian Castro
020cb42e38 fix: typo 2024-03-27 14:59:44 +01:00
Adrian Castro
dca49e8563 feat: context menu for watchhistory items 2024-03-27 13:08:56 +01:00
Adrian Castro
1e653e6540 chore: deps 2024-03-27 13:02:31 +01:00
Adrian Castro
72b2ffefc6 feat: jump to last watched position 2024-03-27 12:52:21 +01:00
Adrian Castro
c828fe3bf6 fix: poster path in meta conversion 2024-03-27 12:41:01 +01:00
Adrian Castro
8c8ad47581 feat: watch history 2024-03-27 12:31:16 +01:00
Adrian Castro
fa2425c183 feat: watch history store 2024-03-27 11:26:41 +01:00
Adrian Castro
e691425248 chore: format 2024-03-27 10:53:21 +01:00
Adrian Castro
6dc1787085 chore: lockfile 2024-03-27 10:52:18 +01:00
Adrian Castro
febc9c5e92 chore: init api package 2024-03-27 10:51:09 +01:00
Adrian Castro
772bee2c1f chore: cleanup 2024-03-27 10:42:46 +01:00
Adrian Castro
42e6b1fe63 feat: add scrapemedia to downloaditem 2024-03-27 10:40:16 +01:00
Adrian Castro
4cfe7b6bfd chore: prolly fix rotation funsies on ios 2024-03-26 22:37:01 +01:00
Adrian Castro
ebfd35c4bb chore: adjust controls for local playback 2024-03-26 21:25:42 +01:00
Adrian Castro
b9f83c3f4f fix: properly cancel downloads 2024-03-26 21:12:58 +01:00
Adrian Castro
a86b1a0ea3 feat: context menu on downloaditem 2024-03-26 20:52:15 +01:00
Adrian Castro
37570b3ee0 feat: cancel downloads 2024-03-26 20:25:33 +01:00
Adrian Castro
1e704bcdd6 feat: hls downloads 2024-03-26 19:57:35 +01:00
Adrian Castro
0566b5ba54 fix: local asset playback 2024-03-26 16:06:13 +01:00
Adrian Castro
1e975ddce4 feat: update sheet modal 2024-03-26 15:43:03 +01:00
Adrian Castro
5e8422b418 chore: cleanup 2024-03-26 14:28:11 +01:00
Adrian Castro
db0c37913c feat: use in tab browser to load update url 2024-03-26 13:33:50 +01:00
Adrian Castro
d6ec5c95e2 chore: explicitly set bundle name 2024-03-26 13:23:38 +01:00
Adrian Castro
3ef800fbec chore: adjust this 2024-03-26 12:57:03 +01:00
Adrian Castro
d8626b9588 chore: cleanup 2024-03-26 12:39:05 +01:00
Adrian Castro
f296cffb06 feat: add/remove bookmarks 2024-03-26 12:34:30 +01:00
Adrian Castro
e15c76e2b6 feat: bookmark store 2024-03-26 12:03:48 +01:00
Adrian Castro
800f0c3481 chore: add repro button for expo issue lol 2024-03-26 11:39:37 +01:00
Adrian Castro
2b77651322 fix: button color 2024-03-26 01:42:49 +01:00
Adrian Castro
c7a3ed35d3 feat: prettier update button 2024-03-26 00:41:03 +01:00
Adrian Castro
7dd708294f chore: uppercase theme selector text 2024-03-26 00:27:57 +01:00
Adrian Castro
ad3411fb3c fix: adjust import 2024-03-25 20:25:49 +01:00
Adrian Castro
0aa9c9d8f7 feat: autoplay 2024-03-25 20:20:07 +01:00
Adrian Castro
37e61d1296 feat: autoplay toggle and setting 2024-03-25 18:57:56 +01:00
Adrian Castro
cd0b302602 refactor: default gesture controls to off on android bc android is funny 2024-03-25 18:48:49 +01:00
Adrian Castro
784628952a refactor: use mmkv and zustand persist middleware for main storage 2024-03-25 16:07:22 +01:00
Adrian Castro
0554dd13bc chore: only use active cache key for pods cache restore 2024-03-24 22:14:36 +01:00
Adrian Castro
f59fbd2c1a chore: formatting 2024-03-24 22:10:03 +01:00
Adrian Castro
f239b4d759 feat: use up-to-date dynamic icon lib that doesn't support android atm 2024-03-24 22:09:39 +01:00
Adrian Castro
a2761b1f7e chore: format 2024-03-24 21:52:08 +01:00
Adrian Castro
44df83c9fb fix: use native modals on iOS & respect safe area 2024-03-24 21:49:28 +01:00
Jorrin
8a9b72ef76 fix search bar focus color 2024-03-24 21:15:06 +01:00
Jorrin
7160d3c137 fix home title fontSize 2024-03-24 20:54:29 +01:00
Jorrin
5c18ff934c format 2024-03-24 20:42:58 +01:00
Jorrin
057e2bfec2 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-03-24 20:41:11 +01:00
Jorrin
ceffab182d fix theme selector not working, add input styling 2024-03-24 20:41:09 +01:00
Adrian Castro
ea435d91de feat: quality selector 2024-03-24 17:36:14 +01:00
Adrian Castro
c567954972 chore: cache entire dir 2024-03-24 13:15:44 +01:00
Adrian Castro
c24b2e01c1 chore: adjust this a-fucking-gain 2024-03-23 17:27:17 +01:00
Adrian Castro
540085c7b1 feat: proper pod cache keys 2024-03-23 17:01:06 +01:00
Adrian Castro
8fed2d5f82 chore: attempt to cache cocoapods 2024-03-23 16:31:12 +01:00
Jorrin
c1268258c7 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-03-23 16:29:30 +01:00
Jorrin
4ec78b13ab add switch theme, remove unneeded search bar context 2024-03-23 16:29:28 +01:00
Adrian Castro
73d56d6eab feat: improve actions caching 2024-03-23 15:38:44 +01:00
Adrian Castro
f5f9450e24 chore: update pod cache paths 2024-03-23 15:21:51 +01:00
Adrian Castro
7308eb2221 chore: perhaps adjust path 2024-03-23 15:02:00 +01:00
Adrian Castro
ddecdf74b2 fix: action order 2024-03-23 14:28:04 +01:00
Adrian Castro
dfbeda217f feat: github actions cache 2024-03-23 14:19:49 +01:00
Adrian Castro
660622805e chore: adjust workflows 2024-03-23 11:08:27 +01:00
Adrian Castro
f148f282e7 chore: just use homebrew instead 2024-03-23 11:04:52 +01:00
Adrian Castro
b53fb74615 chore: use correct repo url for xcbeautify 2024-03-23 11:00:47 +01:00
Adrian Castro
d1c3e89a1d chore: use xcbeautify in actions 2024-03-23 10:56:27 +01:00
Adrian Castro
7e67282df9 feat: update checker 2024-03-23 10:23:07 +01:00
Adrian Castro
c97eb2fb0f fix: remove android prod optimizations 2024-03-23 03:02:19 +01:00
Adrian Castro
2f51f79cea chore: improve build scripts 2024-03-22 22:02:02 +01:00
Adrian Castro
919a3e96fc chore: wrong workflow you idiot 2024-03-22 20:51:31 +01:00
Automated Version Bump
e2e1253270 chore: bump mobile version to 0.0.10 [skip ci] 2024-03-22 19:44:37 +00:00
Jorrin
3ed389aec7 woops 2024-03-22 20:39:49 +01:00
Jorrin
c1b5ceacc3 use insets to respect notch 2024-03-22 20:37:39 +01:00
Jorrin
68e66a6d94 show loading indicator on subtitle selection, padding to progressbar 2024-03-22 20:29:04 +01:00
Jorrin
974eeb73b1 much lint very lint 2024-03-22 19:59:37 +01:00
Jorrin
50a46b1e08 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-03-22 19:55:37 +01:00
Jorrin
f2fe68c31a improve volume and brightness gestures 2024-03-22 19:55:36 +01:00
Adrian Castro
ebad231111 chore: format 2024-03-22 18:53:55 +01:00
Adrian Castro
616e9f76dd chore: improve android bundle size 2024-03-22 18:31:51 +01:00
Jorrin
945a9bf21d optimize volume and brightness overlays 2024-03-21 21:58:41 +01:00
Adrian Castro
86f1210090 chore: toast for bookmark 2024-03-21 15:40:10 +01:00
Adrian Castro
ea4b702c5c feat: link context menu and downloadmanager 2024-03-21 15:26:31 +01:00
Adrian Castro
9c724ec550 chore: expo deps update 2024-03-21 14:27:41 +01:00
Adrian Castro
21b574ee87 feat: play downloads 2024-03-21 14:14:30 +01:00
Adrian Castro
13143a2664 feat: gesture control toggle impl 2024-03-21 12:34:00 +01:00
Adrian Castro
30bf4c3d7a feat: download button in player 2024-03-21 12:06:35 +01:00
Adrian Castro
460580b5c5 feat: toasts 2024-03-21 11:46:25 +01:00
Adrian Castro
315f1aaed1 feat: indicator when copying to library + fix percentage 2024-03-20 20:56:06 +01:00
Adrian Castro
66344d552b feat: remove download history items on long press 2024-03-20 20:07:23 +01:00
Adrian Castro
fe93b9a92f feat: download history 2024-03-20 19:32:39 +01:00
Adrian Castro
bc9116237f chore: add uids to download items 2024-03-20 17:55:26 +01:00
Adrian Castro
f1fc6a9063 chore: newest download first 2024-03-20 17:49:50 +01:00
Adrian Castro
5a8e250bf5 feat: mp4 downloads 2024-03-20 17:41:44 +01:00
Adrian Castro
d3019780a2 chore: add dependencies for download functionality 2024-03-20 16:15:11 +01:00
Adrian Castro
a81975cc02 chore: fix 2024-03-20 14:50:31 +01:00
Adrian Castro
262572dde3 chore: remove native ios modals 2024-03-20 14:44:41 +01:00
Adrian Castro
9c232fd838 fix: settings sheet eating into notch (but now there's some leftover ugly bar that didn't move) 2024-03-20 13:55:37 +01:00
Adrian Castro
e3d252708d fix: ignore tamagui warning 2024-03-20 13:11:08 +01:00
Adrian Castro
f1ddcc02f5 fix: country code mapping 2024-03-20 13:07:53 +01:00
Adrian Castro
9eaf84c991 fix: use OpenSans 2024-03-20 12:13:26 +01:00
Adrian Castro
3b34fb9133 chore: adjust build script 2024-03-20 11:04:27 +01:00
Adrian Castro
68a8b7e593 chore: formatting 2024-03-19 23:16:44 +01:00
Adrian Castro
4ad07265f0 chore: this one seems better 2024-03-19 23:14:17 +01:00
Jorrin
ecc216ef62 same background color as splash image 2024-03-19 22:07:47 +01:00
Jorrin
0ddbc48361 woopsie 2024-03-19 21:27:17 +01:00
Jorrin
76a38dde88 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-03-19 21:25:38 +01:00
Jorrin
22bb3266f7 remove unused dep 2024-03-19 21:25:36 +01:00
Adrian Castro
085436778a chore: prettier 2024-03-19 21:05:49 +01:00
Adrian Castro
5d9e75dd72 chore: change to fork with bumped android sdk 2024-03-19 20:42:02 +01:00
Adrian Castro
134b71eeaf fix: add config plugin to remove notification entitlement during prebuild 2024-03-19 20:05:53 +01:00
Adrian Castro
d3368ef644 fix: use different module that probably builds on windows 2024-03-19 18:40:28 +01:00
Adrian Castro
74ab26a922 chore: cleanup 2024-03-19 16:59:33 +01:00
Adrian Castro
79fbdb4efd chore: add prettierignore 2024-03-19 15:27:45 +01:00
Adrian Castro
01f3d2ef9f chore: bump providers 2024-03-19 14:18:51 +01:00
Adrian Castro
6586c9a412 feat: settings storage & theme persistence 2024-03-19 11:05:13 +01:00
Adrian Castro
f1032f8033 feat: dynamic app icon 2024-03-19 10:24:19 +01:00
Adrian Castro
202e1484f5 chore: adjust gitignore 2024-03-19 06:59:55 +01:00
Jorrin
21ada8162e fix lint 2024-03-18 23:06:38 +01:00
Jorrin
ac0b23db62 remove unused modal 2024-03-18 23:04:02 +01:00
Jorrin
6bb076f4ea woops 2024-03-18 23:02:53 +01:00
Jorrin
52978f6d68 refactor to tamagui 2024-03-18 22:02:54 +01:00
Adrian Castro
069c8cbb89 chore: adjust app config 2024-03-18 16:07:57 +01:00
Adrian Castro
54bc237799 chore: app icon image assets 2024-03-18 16:04:39 +01:00
Adrian Castro
1ce287267e chore: adjust release workflow 2024-03-17 19:23:23 +01:00
Adrian Castro
8697710657 chore: adjust release workflow 2024-03-17 17:47:31 +01:00
Adrian Castro
013453fdf5 chore: clean up 2024-03-11 12:54:35 +01:00
Adrian Castro
7993e569b8 chore: clean up workflows 2024-03-10 10:59:55 +01:00
Adrian Castro
b3e8c7b6b4 chore: this doesn't need to be a scrollview 2024-03-10 10:52:09 +01:00
Adrian Castro
95189818dd chore: cleanup 2024-03-10 10:49:18 +01:00
Adrian Castro
50d6c5ca32 fix: use stylesheets here because nativewind is broken on iOS 2024-03-10 10:41:43 +01:00
Jorrin
4014007a5c Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-03-09 21:46:40 +01:00
Jorrin
0e00115e16 add scraper screen 2024-03-09 21:46:38 +01:00
Adrian Castro
69bcb97889 fix: oops 2024-03-09 19:09:26 +01:00
Adrian Castro
284ead8f75 refactor: unmerge home and search tabs 2024-03-09 19:01:26 +01:00
Adrian Castro
0d135182c1 fix: idiotism 2024-03-09 11:39:21 +01:00
Adrian Castro
bfa0c2b71e fix: play/pause gesture should affect alternate audio tracks 2024-03-09 11:37:54 +01:00
Adrian Castro
b6b8f34d70 fix: ignore pan gesture in slider vicinity 2024-03-09 11:35:43 +01:00
Jorrin
887949ed8a fix lint 2024-03-09 00:41:18 +01:00
Jorrin
70f32abdf8 fix ci 2024-03-09 00:38:53 +01:00
Jorrin
5d9839b987 buttons to purple 2024-03-08 21:57:54 +01:00
Jorrin
ad2c84950a adjust colors to movie-web 2024-03-08 21:53:03 +01:00
Adrian Castro
7e035e823a feat: higher poster quality 2024-03-08 16:36:59 +01:00
Adrian Castro
f272d6614d chore: bounce up audio position by 2 seconds for initial switch 2024-03-08 09:29:53 +01:00
Adrian Castro
17b343f889 feat: horizontal scroll for ItemListSection 2024-03-08 09:14:39 +01:00
Adrian Castro
eb9589b0f7 chore: more workflow adjusting 2024-03-07 18:23:46 +01:00
Adrian Castro
0e8f82c532 chore: update release workflow again 2024-03-07 18:13:56 +01:00
Adrian Castro
3134313e1e chore: set checkout ref in release action 2024-03-07 17:50:54 +01:00
Adrian Castro
12e5b89056 chore: more workflow adjusting 2024-03-07 17:30:40 +01:00
Adrian Castro
b083cbd9ec chore: bump action version 2024-03-07 16:56:36 +01:00
Adrian Castro
88e608137e chore: update workflow again 2024-03-07 16:31:06 +01:00
Adrian Castro
f4040c9c21 chore: adjust workflow 2024-03-07 16:07:03 +01:00
Adrian Castro
bfc23ee8b4 chore: update workflow 2024-03-07 15:42:23 +01:00
Adrian Castro
6deb39e8a7 feat: finish audiotrack switching 2024-03-07 13:12:14 +01:00
Adrian Castro
fd1928c43d chore: add logs to useaudiotrack 2024-03-07 11:16:07 +01:00
Adrian Castro
c9222b0760 chore: cleanup 2024-03-06 20:21:25 +01:00
Adrian Castro
9a4d99827f chore: add comment with multi audio stream 2024-03-06 20:12:30 +01:00
Adrian Castro
fcec9bcdd3 chore: cleanup 2024-03-06 16:28:41 +01:00
Adrian Castro
7533179287 feat: loading screen prep 2024-03-06 15:23:43 +01:00
Adrian Castro
ed947d3444 chore: bump providers 2024-03-06 14:26:57 +01:00
Adrian Castro
6e3aabf369 fix: audio track selection 2024-03-06 14:24:21 +01:00
Adrian Castro
b5a7e58e66 refactor: make audiotrack stuff its own hook 2024-03-06 13:23:34 +01:00
Adrian Castro
6b5ee9aba0 feat: finish playback speed stuff 2024-03-06 12:45:54 +01:00
Adrian Castro
ce38ece1ca chore: set this back 2024-03-06 12:29:23 +01:00
Adrian Castro
23397e7ecc chore: update grey shade to white bc of whatever ios 17.4 did 2024-03-06 10:10:10 +01:00
Adrian Castro
701dfcaa09 chore: update title 2024-03-06 09:34:36 +01:00
Adrian Castro
7dfd14842b chore: only focus when search button is tapped 2024-03-06 08:41:00 +01:00
Adrian Castro
fdfec592a9 chore: adjust workflow 2024-03-06 08:39:25 +01:00
Adrian Castro
9b329496ed chore: adjust workflow 2024-03-05 21:42:44 +01:00
Adrian Castro
27380e57da feat: restructure internal tab stuff to make more sense 2024-03-05 21:27:20 +01:00
Adrian Castro
d07c49b8c3 feat: configure altstore source 2024-03-05 21:02:04 +01:00
Adrian Castro
d4f0dc008f chore: workflow stuff 2024-03-05 20:23:56 +01:00
Adrian Castro
c65b2a8228 feat: settingsscreen placeholder stuff 2024-03-05 20:13:13 +01:00
Adrian Castro
0dbfd4c2be feat: downloaditem placeholder 2024-03-05 19:53:24 +01:00
Adrian Castro
0bf34e4ea7 fix: use scrollview 2024-03-05 19:16:22 +01:00
Adrian Castro
ed27c90394 fix: some animation stuff 2024-03-05 19:14:00 +01:00
Adrian Castro
c50ad167e0 fix: text styling 2024-03-05 18:48:44 +01:00
Adrian Castro
7d1d8ce84d fix: update state properly 2024-03-05 18:37:32 +01:00
Adrian Castro
c61522c222 feat: show home screen until searchresults are loaded 2024-03-05 18:22:45 +01:00
Adrian Castro
e3255443e0 feat: home screen placeholder 2024-03-05 17:48:14 +01:00
Adrian Castro
0aa28b4c54 fix: well this effect is probably needed although this shit refuses to work 2024-03-01 20:07:43 +01:00
Adrian Castro
ae760a4b9b feat: playback speed changing 2024-03-01 14:47:35 +01:00
Adrian Castro
9c00fc2f54 fix: update to providers to make showbox work 2024-02-29 20:14:19 +01:00
Adrian Castro
10858c6c8e fix: filter duplicate audio tracks 2024-02-24 10:38:43 +01:00
Adrian Castro
a43cb420d5 fix: portrait orientation when tabs focused 2024-02-23 16:17:57 +01:00
Adrian Castro
b6782c4493 fix: filter out duplicate audiotracks 2024-02-23 16:08:36 +01:00
Adrian Castro
7c3fcfcd4e feat: audio track switching 2024-02-23 11:55:49 +01:00
Adrian Castro
56b834fc16 feat: audiotrack loading 2024-02-23 11:41:17 +01:00
Adrian Castro
978dc76c54 feat: checkmark if track is active 2024-02-23 11:26:25 +01:00
Adrian Castro
36b99df477 chore: cleanup 2024-02-23 11:15:22 +01:00
Adrian Castro
0b9ada60a4 chore: add selected audiotrack variable 2024-02-23 11:13:17 +01:00
Adrian Castro
45cd1f8a3a feat: construct full audio url 2024-02-23 11:03:27 +01:00
Adrian Castro
aaa6a8af21 chore: add selector to player controls 2024-02-23 10:11:08 +01:00
Adrian Castro
39d5acee77 feat: audio track selector 2024-02-23 10:08:26 +01:00
Adrian Castro
410de846cd chore: log audio tracks 2024-02-23 09:50:48 +01:00
Adrian Castro
825832769b feat: audio track store 2024-02-23 09:30:28 +01:00
Adrian Castro
2b7eb3ebb0 feat: audio track stuff 2024-02-23 09:11:26 +01:00
Adrian Castro
271e6be96e chore: adjust haptic feedback 2024-02-23 06:46:09 +01:00
Adrian Castro
2876cfd8e9 chore: more stuff 2024-02-22 18:45:54 +01:00
Adrian Castro
23cfcb8b1a feat: haptic feedback 2024-02-22 18:30:58 +01:00
Adrian Castro
16ed0f8a6a chore: some adjustments 2024-02-22 18:28:35 +01:00
Adrian Castro
3955956bc4 feat: show checked source in scraperprocess 2024-02-21 13:06:02 +01:00
Adrian Castro
bd32f2f120 chore: bump action version 2024-02-21 10:08:57 +01:00
Adrian Castro
de24089fa3 chore: cleanup 2024-02-21 08:03:10 +01:00
Jorrin
347348d200 added languages with urlsearchparams polyfill that is broken 2024-02-20 23:48:44 +01:00
Jorrin
99f386ef1a Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-02-20 21:53:28 +01:00
Jorrin
5e0ca0f43d fix layout for selector buttons 2024-02-20 21:53:26 +01:00
Adrian Castro
49a6596388 fix: adjust values and overlay visibilty based on gesture velocity change 2024-02-20 21:07:48 +01:00
Adrian Castro
b41f929f6e feat: always focus input when search tab is pressed 2024-02-20 20:38:02 +01:00
Adrian Castro
987d051fee fix: scrollview shouldn't scroll when no results 2024-02-20 20:13:52 +01:00
Adrian Castro
5b465f81f7 fix: make time display more efficient 2024-02-20 17:23:42 +01:00
Adrian Castro
d42de8cb12 feat: timeout if media doesn't play after one minute 2024-02-20 17:07:35 +01:00
Adrian Castro
01d2028dbe fix: conditional operator 2024-02-20 16:31:33 +01:00
Adrian Castro
b141f8dd79 feat: source timeout 2024-02-20 16:28:51 +01:00
Adrian Castro
31f6a7e851 chore: cleanup 2024-02-20 16:18:59 +01:00
Adrian Castro
45b924911c feat: show controls while loading 2024-02-20 16:10:01 +01:00
Adrian Castro
6dab85f945 chore: formatting 2024-02-20 09:07:26 +01:00
Adrian Castro
cc7f5ca0a4 fix: use enum value 2024-02-20 09:06:25 +01:00
Adrian Castro
0d0a66151b chore: regular enum 2024-02-20 09:05:23 +01:00
Adrian Castro
b3db62263e fix: use enum values 2024-02-20 09:04:09 +01:00
Adrian Castro
e0ee1c00b9 chore: use enum 2024-02-20 09:01:56 +01:00
Jorrin
b387273573 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-02-19 22:12:10 +01:00
Jorrin
90c6c2093b improve loading, caption renderer, season/episode selector, source selector 2024-02-19 22:12:08 +01:00
Adrian Castro
b008531c07 chore: cleanup 2024-02-19 18:33:59 +01:00
Adrian Castro
efab11bff5 feat: movie-web tab 2024-02-18 22:22:50 +01:00
Adrian Castro
8914cca32c feat: slight visual indication that search tab is focused 2024-02-18 21:37:46 +01:00
Adrian Castro
62fdd3e99c feat: synchronize searchbar animation with keyboard movement 2024-02-18 21:06:29 +01:00
Adrian Castro
fe488b5e8b chore: add comment 2024-02-18 16:14:51 +01:00
Adrian Castro
45a61a67ea feat: episode selection 2024-02-18 15:54:04 +01:00
Adrian Castro
5032bcd77b fix: source id is string 2024-02-18 15:45:41 +01:00
Adrian Castro
7a7fbb99fa feat: fetch and store season data 2024-02-18 14:12:59 +01:00
Adrian Castro
7e51aad0c1 feat: fetchSeasonDetails function 2024-02-18 13:59:03 +01:00
Adrian Castro
7a81560974 feat: non functional episode/season selector 2024-02-18 13:26:46 +01:00
Adrian Castro
5f99e0cac4 fix: router.replace() throws exception 2024-02-18 13:04:36 +01:00
Adrian Castro
ec1300c6d6 feat: source selection & ugly source selector 2024-02-18 12:22:07 +01:00
Adrian Castro
68ec709c51 feat: allow specific source id in getVideoStream 2024-02-18 11:10:19 +01:00
Jorrin
a63bee2923 fix caption positioning 2024-02-18 03:28:45 +01:00
Jorrin
44db833c00 fix header not being clickable 2024-02-17 14:49:56 +01:00
Adrian Castro
e927dbb6a8 chore: missing dep in array 2024-02-17 10:23:28 +01:00
Jorrin
52eab1e8e8 first version of a really buggy and ugly caption selector and renderer 2024-02-16 21:25:29 +01:00
Adrian Castro
d9964f5a72 fix: slider lenght & duration position 2024-02-16 14:41:36 +01:00
Adrian Castro
404c269e8d fix: double dash oops 2024-02-16 13:18:32 +01:00
Adrian Castro
eaeb535208 feat: move duration above progress bar 2024-02-16 11:36:59 +01:00
Adrian Castro
a991882484 fix: scrollview shouldn't scroll when no results 2024-02-16 11:04:50 +01:00
Adrian Castro
c811800afb fix: context menu long press on android 2024-02-16 09:02:29 +01:00
Adrian Castro
ff3bd54fcd chore: use platform select here as well 2024-02-15 23:22:38 +01:00
Adrian Castro
1676bc71d3 chore: higher value for android maybe 2024-02-15 23:17:58 +01:00
Jorrin
3b07c10f86 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-02-15 23:00:14 +01:00
Jorrin
1a08ed0c10 opacity on overlay 2024-02-15 23:00:12 +01:00
Adrian Castro
2723c44b08 feat: searchbar follows keyboard 2024-02-15 22:50:12 +01:00
Adrian Castro
37360c4277 feat: searchbar UX 2024-02-15 22:13:48 +01:00
Adrian Castro
4f86d44f35 chore: add type 2024-02-15 21:04:51 +01:00
Adrian Castro
149daa3435 feat: pretty native context menu on search items 2024-02-15 20:59:54 +01:00
Adrian Castro
b3dbb7f334 chore: more cleanup 2024-02-15 20:09:19 +01:00
Adrian Castro
53106d8b7b chore: cleanup 2024-02-15 20:07:48 +01:00
Adrian Castro
b81ff76d98 refactor: use parse-hls 2024-02-15 20:00:59 +01:00
Jorrin
9147472b84 fix types 2024-02-15 19:47:28 +01:00
Adrian Castro
36678a6580 feat: show remaining time in bottomcontrols when time is tapped 2024-02-15 19:28:03 +01:00
Jorrin
76c277ac96 revert 2024-02-15 19:19:01 +01:00
Jorrin
33b2f04da6 Update tsconfig.json 2024-02-15 19:04:07 +01:00
Adrian Castro
c0d0730cfe chore: formatting 2024-02-15 12:04:00 +01:00
Adrian Castro
bf19b1c8ed chore: add this for when I find media with tracks 2024-02-15 12:03:43 +01:00
Adrian Castro
bbff23985b chore: fine keep failing then 2024-02-15 11:49:34 +01:00
Adrian Castro
4090869b48 chore: come on ci 2024-02-15 11:47:26 +01:00
Adrian Castro
4aa964d1e1 chore: adjustment 2024-02-15 11:43:13 +01:00
Adrian Castro
0ab9ebbcc6 chore: typeroots 2024-02-15 11:42:57 +01:00
Adrian Castro
35a3ab8050 feat: add function to parse hls tracks 2024-02-15 10:56:48 +01:00
Adrian Castro
4d754061ea chore: bump action versions 2024-02-15 10:24:41 +01:00
Adrian Castro
6ecf3f5841 chore: make this a useful placeholder tab 2024-02-14 22:56:39 +01:00
Adrian Castro
8da4ad579c chore: clean this stuff up too 2024-02-14 22:48:09 +01:00
Adrian Castro
4d8a61baba chore: cleanup 2024-02-14 22:39:32 +01:00
Adrian Castro
83dd90e61c chore: wording 2024-02-14 22:38:45 +01:00
Adrian Castro
3e4a6cc3b2 feat: detect pan gesture direction and adjust values accordingly 2024-02-14 22:38:18 +01:00
Adrian Castro
e72be7af6c chore: adjust some gesture stuff 2024-02-14 22:18:36 +01:00
Jorrin
61f3e77f58 fix slider resetting while sliding 2024-02-14 22:18:07 +01:00
Adrian Castro
439ba8c7e5 fix: fix the controls so they don't intefere with bottom controls 2024-02-14 21:53:33 +01:00
Adrian Castro
6ebdb6820a fix: control overlay on iOS 2024-02-14 21:49:51 +01:00
Jorrin
52e90c6039 fix slider not triggering idle 2024-02-14 21:32:16 +01:00
Jorrin
94c3ad5862 fix brightness positioning 2024-02-14 21:24:50 +01:00
Jorrin
8556ad3875 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-02-14 20:00:42 +01:00
Jorrin
c670047713 volume cleanup 2024-02-14 20:00:36 +01:00
Adrian Castro
5d48b6a7c4 chore: this should work whenever expo-router gets a release 2024-02-14 18:55:30 +01:00
Jorrin
82a3f431fa fix slider progress color 2024-02-14 18:32:37 +01:00
Jorrin
ea6698b6e4 cleanup brightness hooks 2024-02-14 17:43:35 +01:00
Jorrin
a7392e92c9 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-02-14 15:10:58 +01:00
Jorrin
e6ace2615f only add bottom padding on ios 2024-02-14 15:10:52 +01:00
Adrian Castro
88da9895f9 feat: pretty overlays for gesture controls 2024-02-14 15:08:16 +01:00
Adrian Castro
bd6c2409c3 feat: gesture controls 2024-02-14 14:49:10 +01:00
Jorrin
91d85deccb adjust width 2024-02-14 14:41:32 +01:00
Jorrin
88a3ea6f5d Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-02-14 14:36:57 +01:00
Jorrin
c140fa885b add buggy videoslider 2024-02-14 14:36:56 +01:00
Adrian Castro
7b1dcad3db fix: add bottom padding to tabbar (too low on iOS) 2024-02-14 13:29:36 +01:00
Adrian Castro
1fad7dbfc6 chore: cleanup 2024-02-14 11:46:57 +01:00
Adrian Castro
a2b70eee3a fix: control touch events on iOS 2024-02-14 11:46:14 +01:00
Jorrin
a4f4f6822d ios? 2024-02-13 23:05:46 +01:00
Jorrin
85372e5e5c setup progress bar without functionalities 2024-02-13 21:50:02 +01:00
Jorrin
2bd284a147 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-02-13 21:14:17 +01:00
Jorrin
378b16b3e4 Improve layout, add current time and duration 2024-02-13 21:13:48 +01:00
Adrian Castro
649a94844a feat: double tap screen to play/pause 2024-02-13 21:08:24 +01:00
Adrian Castro
0a98e86de1 feat: pinch-to-zoom video 2024-02-13 20:49:43 +01:00
Adrian Castro
5c5a8bf64d fix: explicity check for undefined in headerdata 2024-02-13 20:16:32 +01:00
Adrian Castro
3b84adf645 fix: keyboard catching taps on search component 2024-02-13 20:03:37 +01:00
Adrian Castro
0468b2377d feat: dismiss keyboard when navigating out of video player 2024-02-13 17:50:57 +01:00
Adrian Castro
b04c161a94 fix: hide iOS home indicator when no touch 2024-02-13 15:57:39 +01:00
Adrian Castro
5a23ffed69 chore: apparently this was fine the way it was, back button works 2024-02-13 15:33:54 +01:00
Adrian Castro
15af963aaa chore: I love vscode 2024-02-13 12:25:16 +01:00
Adrian Castro
e7f0d4950a fix: fix controls overlapping back button without accidentally moving controls 2024-02-13 12:22:39 +01:00
Adrian Castro
f6b5f3d342 Revert "fix: middlecontrols blocking back button"
This reverts commit 38419aa385.
2024-02-13 12:19:44 +01:00
Adrian Castro
38419aa385 fix: middlecontrols blocking back button 2024-02-13 12:18:54 +01:00
Adrian Castro
e5dc36cd6d fix: player controls touch events on iOS 2024-02-13 12:10:56 +01:00
Adrian Castro
6e33e0efea chore: log color adjustment 2024-02-13 11:13:25 +01:00
Adrian Castro
9db0ae544c feat: prettier loading log 2024-02-13 11:07:50 +01:00
Adrian Castro
c88ebe9715 feat: event types and better loading log 2024-02-13 09:04:01 +01:00
Adrian Castro
26e896b647 refactor: move video player routes onto their own dir 2024-02-12 22:01:02 +01:00
Adrian Castro
239d201d9f fix: setVisibilityAsync is android only 2024-02-12 21:43:52 +01:00
Adrian Castro
8516060bc7 chore: prettier 2024-02-12 21:38:34 +01:00
Adrian Castro
8dde4a8cd0 feat: provider event logic & temp loading screen 2024-02-12 21:37:54 +01:00
Jorrin
61cb948f3d remove navigation bars when in fullscreen 2024-02-12 20:51:56 +01:00
Jorrin
9a04824c02 oops 2024-02-12 20:07:55 +01:00
Jorrin
7dc0512007 rename file 2024-02-12 20:07:36 +01:00
Adrian Castro
f18a5421e5 refactor: cleanup headerdata stuff 2024-02-12 19:26:00 +01:00
Adrian Castro
a397974325 chore: prettier 2024-02-12 19:04:04 +01:00
Adrian Castro
68ff77ec99 feat: show season and episode in header if available 2024-02-12 18:59:52 +01:00
Jorrin
5bc848ed5f add play and seek buttons 2024-02-12 18:47:20 +01:00
Jorrin
9dbe9e663f fix idle timeout 2024-02-12 16:19:59 +01:00
Jorrin
66ac4730bd cleanup 2024-02-12 16:13:42 +01:00
Jorrin
f362863326 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-02-12 16:11:37 +01:00
Jorrin
094c0382a6 introduce store with idle tracking 2024-02-12 16:11:35 +01:00
Adrian Castro
70d074f386 chore: cleanup 2024-02-12 15:28:12 +01:00
Adrian Castro
33a62752e2 feat: convert srt to vtt if required 2024-02-12 15:26:54 +01:00
Adrian Castro
3d1a5a88f2 chore: cleanup & pass title to header 2024-02-12 13:41:42 +01:00
Jorrin
c5a5fd8eb6 nativewind not working on video player 2024-02-12 02:39:19 +01:00
Jorrin
08463222e5 remove non used colors 2024-02-11 23:06:48 +01:00
Jorrin
35b3739847 Merge branch 'feat-providers-video' of https://github.com/castdrian/mw-native into pr/9 2024-02-11 22:58:58 +01:00
Jorrin
5773f00cd3 add back button and header layout to player 2024-02-11 22:58:39 +01:00
Adrian Castro
b139a4a7ff chore: comment detail 2024-02-11 22:40:31 +01:00
Adrian Castro
51e24bec27 chore: prettier 2024-02-11 22:32:26 +01:00
Adrian Castro
7483d6b973 fix: iOS only supports vtt captions 2024-02-11 22:31:42 +01:00
Adrian Castro
07096f0dec chore: prettier 2024-02-11 22:18:59 +01:00
Adrian Castro
63512d2596 chore: add default fullscreen orientation value 2024-02-11 22:07:30 +01:00
Jorrin
2dd7eb49bb fix: react-native crypto on android 2024-02-11 19:40:20 +01:00
Adrian Castro
c52c3309fe chore: adjust fuction name 2024-02-11 15:05:37 +01:00
Adrian Castro
69e6e4ea25 feat: have workflow build on demand via /build comment 2024-02-10 12:33:28 +01:00
Adrian Castro
367e4ce8fb chore: use camelcase 2024-02-10 12:00:00 +01:00
Adrian Castro
a270c94e03 feat: use quick crypto 2024-02-10 11:21:14 +01:00
Adrian Castro
1a44c10c0d feat: use actual mw icons 2024-02-10 09:44:21 +01:00
Adrian Castro
3e6c5147cd chore: set prettier as default for workflow files 2024-02-10 09:34:28 +01:00
Adrian Castro
e1ae9136e1 chore: prettier 2024-02-10 09:23:41 +01:00
Adrian Castro
df53ee610e feat: comment built binaries on pull request 2024-02-10 09:21:25 +01:00
Adrian Castro
d9a03907e0 chore: rename job 2024-02-10 09:14:07 +01:00
Adrian Castro
fda8f34dab feat: create ios altstore repo.json on release 2024-02-10 09:12:57 +01:00
Adrian Castro
15f3de83e4 chore: use nativewind css classes with view instead of safeareaview 2024-02-06 10:45:59 +01:00
Adrian Castro
e11dc1dbb2 feat: convert captions to texttracks 2024-02-06 10:32:14 +01:00
Adrian Castro
dd9241a015 chore: make comments clearer 2024-02-06 09:43:13 +01:00
Jorrin
eef7565106 moved files outside of /app 2024-02-05 21:03:26 +01:00
Jorrin
eeb0b921dc add imdbId to scrape media 2024-02-05 20:51:46 +01:00
Adrian Castro
a6a3f8042f chore: forgot what this commit did 2024-02-05 19:55:34 +01:00
Adrian Castro
8db85c545b feat: set headers in video player 2024-02-05 19:49:00 +01:00
Adrian Castro
e39ee1373b feat: move source fetching logic into player and remove double player nonsense 2024-02-05 19:15:41 +01:00
Adrian Castro
6fbea58edc chore: use nativewind classes 2024-02-05 18:34:51 +01:00
Adrian Castro
61be1c37ac refactor: make video player function component 2024-02-05 18:32:38 +01:00
Adrian Castro
28126f612a chore: rogue log goes home 2024-02-05 18:24:20 +01:00
Adrian Castro
552b9b52bc feat: load video from providers 2024-02-05 12:27:46 +01:00
Adrian Castro
8976b939b6 feat: add getVideoUrl function 2024-02-05 11:04:38 +01:00
Adrian Castro
667bf4ab13 feat: pass video url to player 2024-02-05 10:33:44 +01:00
Adrian Castro
8e03075ebc feat: video player orientation 2024-02-05 09:32:53 +01:00
Adrian Castro
55be0860b9 feat: add video player 2024-02-04 22:45:30 +01:00
Adrian Castro
0728ab6b49 chore: update handlebars 2024-02-04 22:34:29 +01:00
Adrian Castro
1bf1b8898f chore: adjust readme 2024-02-04 21:07:20 +01:00
Adrian Castro
d42b5fbb45 chore: add react-native-video dependency 2024-02-04 21:05:12 +01:00
Adrian Castro
8593d76984 chore: init providers package 2024-02-04 20:41:56 +01:00
Jorrin
a3f184979e Merge pull request #8 from castdrian/feat-tmdb
feat: tmdb package & github action builds
2024-02-04 19:55:43 +01:00
Jorrin
450ef4dc90 add no cache option and cleanup 2024-02-04 19:53:45 +01:00
Adrian Castro
4e60002bac fix: search button css on ios is a round boi now 2024-02-04 19:46:41 +01:00
Adrian Castro
7941a35111 chore: don't run this for all pr events 2024-02-04 19:34:50 +01:00
Adrian Castro
519c85a3ac chore: clean stuff in item component 2024-02-04 19:30:46 +01:00
Adrian Castro
0134c0ff92 refactor: abstract build and release workflows into seperate workflows 2024-02-04 19:18:24 +01:00
Adrian Castro
4478366855 refactor: address code review 2024-02-04 18:36:21 +01:00
Adrian Castro
54e270bf17 chore: run prettier 2024-02-04 17:48:04 +01:00
Adrian Castro
cf4076d613 chore: catch search 2024-02-04 17:46:26 +01:00
Adrian Castro
ef78cc3447 chore: cleanup 2024-02-04 17:38:57 +01:00
Adrian Castro
1b9fbb4120 chore: use typedef instead of inline type 2024-02-04 17:38:03 +01:00
Adrian Castro
e88b7d2051 chore: run prettier 2024-02-04 16:52:27 +01:00
Adrian Castro
aa0e374bca chore: more cleanup 2024-02-04 16:44:55 +01:00
Adrian Castro
271cca3cd5 chore: cleanup 2024-02-04 16:34:21 +01:00
Adrian Castro
7c9247dc2c chore: cleanup 2024-02-04 16:25:36 +01:00
Adrian Castro
dc6e3f5a7f chore: run prettier 2024-02-04 16:15:21 +01:00
Adrian Castro
e3f74aac09 feat: implement search via tmdb package 2024-02-04 16:14:16 +01:00
Adrian Castro
b4e9ff5086 chore: stuff 2024-02-04 14:52:10 +01:00
Adrian Castro
c4a56c1a2a chore: run prettier 2024-02-04 14:00:12 +01:00
Adrian Castro
3a4df634cf feat: implement tmdb package 2024-02-04 13:58:33 +01:00
Adrian Castro
c2b6b6a555 chore: bump setup node action version 2024-02-04 13:43:08 +01:00
Adrian Castro
360bdf4f23 fix: adjust workflow paths 2024-02-04 13:32:07 +01:00
Adrian Castro
1a9f955a37 feat: init tmdb package 2024-02-04 12:30:09 +01:00
Adrian Castro
94ef89a95f chore: use actual name of the built executable 2024-02-04 12:26:33 +01:00
Adrian Castro
0c1e67291a chore: run prettier 2024-02-04 12:17:59 +01:00
Adrian Castro
b800574a26 fix: additional ci build config stuffs 2024-02-04 12:03:06 +01:00
Adrian Castro
6e53e00757 feat: ci builds 2024-02-04 11:57:24 +01:00
Adrian Castro
a4777e442e chore: metadata 2024-02-04 11:40:24 +01:00
Jorrin
415a2541fe fix ci on master 2024-02-03 22:20:54 +01:00
Jorrin
ea30054508 Merge pull request #7 from movie-web/turbo
refactor to turbo
2024-02-03 21:48:03 +01:00
Jorrin
d59b485167 fix 2024-02-03 21:43:14 +01:00
Jorrin
a56dac54fe format and remove unused externallink component 2024-02-03 21:38:58 +01:00
Jorrin
d261779b6d Update ci.yml 2024-02-03 21:34:53 +01:00
Jorrin
82172727e9 update deps and fix navbar icon 2024-02-03 21:31:19 +01:00
Jorrin
add7c1841d Update config.ts 2024-02-03 20:40:45 +01:00
Jorrin
df8bc8a83f Update LICENSE 2024-02-03 20:40:01 +01:00
Jorrin
28467cdf24 refactor to turbo 2024-02-03 20:33:27 +01:00
Jorrin
fc5c60f85b Merge pull request #6 from movie-web/nativewind
Nativewind
2024-01-30 19:12:01 +01:00
Jorrin
f5a5929972 Merge branch 'nativewind' of https://github.com/movie-web/native-app into nativewind 2024-01-29 10:49:38 +01:00
Jorrin
cf17593b57 remove unnecessary classes 2024-01-29 10:49:32 +01:00
Jorrin
e7d7b046db Merge branch 'dev' into nativewind 2024-01-29 10:36:07 +01:00
Jorrin
a7608b878d cleanup 2024-01-29 10:34:51 +01:00
Jorrin
5baf4d6b86 helpppp 2024-01-29 10:09:00 +01:00
Jorrin
e83bf1c806 test 2024-01-29 00:06:03 +01:00
Jorrin
865cd632d6 pain 2024-01-28 23:58:54 +01:00
Jorrin
89d1310eac help 2024-01-28 21:53:06 +01:00
Jorrin
8977e3ea2c upgrade to v4 2024-01-27 23:20:08 +01:00
Jorrin
4c634abc1e upgrade to native wind v4 2024-01-27 13:53:41 +01:00
Jorrin
26a1b623e7 replace all stylesheets with tailwind classes 2024-01-23 21:56:17 +01:00
Jorrin
5e47c5e5f7 Merge pull request #5 from castdrian/patch-1
fix(workflow): bump version before building lol
2024-01-23 07:59:34 +01:00
Adrian Castro
9c310c01c8 fix(workflow): bump version before building lol 2024-01-23 01:31:41 +01:00
Jorrin
8a48a1cce4 first setup 2024-01-22 22:43:19 +01:00
Jorrin
910c3f4b3b fix deps 2024-01-22 19:55:23 +01:00
Jorrin
37d21eea56 Merge pull request #4 from callmearta/tabs
[feat] tabs added
2024-01-22 18:17:20 +01:00
Jorrin
cd2c07f586 Merge branch 'dev' into tabs 2024-01-22 18:17:05 +01:00
Arta
4041b9b393 [fix] fix comments on PR 2024-01-20 15:36:57 +03:30
Arta
ad82e72969 [ui] font added, movie item added, pages layout 2024-01-18 15:41:42 +03:30
Arta
1865d2e6a8 [feat] tabs added 2024-01-15 17:30:21 +03:30
Jorrin
8e2cf0f28d Merge pull request #2 from movie-web/setup
Added basic expo skeleton app
2024-01-15 13:48:23 +01:00
Jorrin
eaa9706244 Merge pull request #3 from castdrian/build-workflow
feat: build pipelines & nx-expo app link
2024-01-15 13:39:28 +01:00
castdrian
d887e9f207 chore: adjust ios configs in generated configs 2024-01-15 13:17:18 +01:00
castdrian
826ae13777 chore: remove redundant autogenerated file 2024-01-15 12:48:32 +01:00
castdrian
6ec1a3fb64 feat: release job in workflow 2024-01-15 10:22:53 +01:00
castdrian
f2f46368d9 fix: fix the asset paths 2024-01-15 09:07:48 +01:00
castdrian
e64a52f5c3 fix: fix the routing back to how it was 2024-01-15 09:03:43 +01:00
castdrian
314e739af5 fix: restore the expo router stuff that the autogenerated stuff undid 2024-01-15 08:43:44 +01:00
castdrian
9f4cb15eba chore: cleanup 2024-01-15 07:11:04 +01:00
castdrian
8142f312b6 chore: unstage ignored files 2024-01-15 07:08:29 +01:00
castdrian
1f3c358f0a feat: a bit of build documentation because ios is crazy stuff 2024-01-15 07:02:58 +01:00
castdrian
5ffee47224 feat: android build 2024-01-15 05:58:36 +01:00
Adrian Castro
53297b820c chore: update workflow 2024-01-15 05:17:34 +01:00
castdrian
54558e9799 chore: bump dependency that was at fault all this time 2024-01-15 05:02:21 +01:00
castdrian
74e9954b9c chore: workflow 2024-01-15 04:55:04 +01:00
castdrian
49318dca38 chore: change jsengine to jsc in top level since it fails to build otherwise 2024-01-15 00:40:27 +01:00
castdrian
dcdb59ddd5 chore: disable hermes on ios 2024-01-15 00:28:11 +01:00
castdrian
20a0fbbcfb chore: add pod-install 2024-01-14 21:32:22 +01:00
castdrian
9238d58900 chore: prebuild config 2024-01-14 21:25:56 +01:00
castdrian
9f37eaa006 feat: actually link expo and nx 2024-01-14 21:24:49 +01:00
castdrian
8f673cc7f3 chore: bump ios target to required minimum 2024-01-14 20:40:20 +01:00
castdrian
ea372b1437 chore: add expo build properties plugin 2024-01-14 20:30:43 +01:00
castdrian
817b9ad771 chore: initial setup for automated builds 2024-01-14 19:17:39 +01:00
Jorrin
2e1e239be5 remove unused extension 2024-01-14 15:07:28 +01:00
Jorrin
3be4830711 fix license and readme 2024-01-14 15:05:13 +01:00
Jorrin
326ff1fe92 add expo app 2024-01-14 14:56:39 +01:00
Jorrin
833a1c8ecd init 2024-01-14 02:26:22 +01:00
mrjvs
38e0dd87e0 Folder structure 2024-01-13 22:01:07 +01:00
mrjvs
8de5672832 add github files 2024-01-13 21:57:01 +01:00
197 changed files with 38584 additions and 2 deletions

40
.fleet/run.json Normal file
View File

@@ -0,0 +1,40 @@
{
"configurations": [
{
"type": "command",
"name": "Run iOS",
"program": "pnpm",
"args": [
"ios"
],
"workingDir": "apps/expo/",
},
{
"type": "command",
"name": "Run Android",
"program": "pnpm",
"args": [
"android"
],
"workingDir": "apps/expo/",
},
{
"type": "command",
"name": "Build IPA",
"program": "pnpm",
"args": [
"ipa"
],
"workingDir": "apps/expo/",
},
{
"type": "command",
"name": "Build APK",
"program": "pnpm",
"args": [
"apk"
],
"workingDir": "apps/expo/",
},
]
}

5
.fleet/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"editor.formatOnSave": true,
"nodejs.editor.formatOnSave.prettier.mode": "Enabled",
"nodejs.editor.formatOnSave.eslint.mode": "Enabled"
}

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @movie-web/core

1
.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@@ -0,0 +1 @@
Please visit the [main document at primary repository](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md).

1
.github/CONTRIBUTING.md vendored Normal file
View File

@@ -0,0 +1 @@
Please visit the [main document at primary repository](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md).

15
.github/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,15 @@
# Security Policy
## Supported Versions
The movie-web maintainers only support the latest version of movie-web published at https://movie-web.app.
This published version is equivalent to the master branch.
Support is not provided for any forks or mirrors of movie-web.
## Reporting a Vulnerability
There are two ways you can contact the movie-web maintainers to report a vulnerability:
- Email [security@movie-web.app](mailto:security@movie-web.app)
- Report the vulnerability in the [movie-web Discord server](https://discord.movie-web.app)

6
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,6 @@
This pull request resolves #XXX
- [ ] I have read and agreed to the [code of conduct](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md).
- [ ] I have read and complied with the [contributing guidelines](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md).
- [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/movie-web/movie-web/projects).
- [ ] I have tested all of my changes.

View File

@@ -0,0 +1,112 @@
name: "build mobile app via /build"
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
jobs:
build-android:
runs-on: ubuntu-latest
if: github.event.issue.pull_request && contains(github.event.comment.body, '/build')
steps:
- uses: xt0rted/pull-request-comment-branch@v2
id: comment-branch
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ steps.comment-branch.outputs.head_ref }}
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 9
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: "**/node_modules"
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Build Android app
run: cd apps/expo && pnpm apk
- name: Upload movie-web.apk as artifact
uses: actions/upload-artifact@v4
with:
name: apk
path: ./apps/expo/android/app/build/movie-web.apk
build-ios:
runs-on: macos-14
if: github.event.issue.pull_request && contains(github.event.comment.body, '/build')
steps:
- uses: xt0rted/pull-request-comment-branch@v2
id: comment-branch
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ steps.comment-branch.outputs.head_ref }}
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 9
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: "**/node_modules"
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Cache Pods
uses: actions/cache@v4
with:
path: apps/expo/ios
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Build iOS app
run: cd apps/expo && pnpm ipa
- name: Upload movie-web.ipa as artifact
uses: actions/upload-artifact@v4
with:
name: ipa
path: ./apps/expo/ios/build/movie-web.ipa

103
.github/workflows/build-mobile.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: build mobile app
on:
pull_request:
types: [opened, ready_for_review]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 9
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: "**/node_modules"
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Build Android app
run: cd apps/expo && pnpm apk
- name: Upload movie-web.apk as artifact
uses: actions/upload-artifact@v4
with:
name: apk
path: ./apps/expo/android/app/build/movie-web.apk
build-ios:
runs-on: macos-14
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 9
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: "**/node_modules"
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Cache Pods
uses: actions/cache@v4
with:
path: apps/expo/ios
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Build iOS app
run: cd apps/expo && pnpm ipa
- name: Upload movie-web.ipa as artifact
uses: actions/upload-artifact@v4
with:
name: ipa
path: ./apps/expo/ios/build/movie-web.ipa

53
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: CI
on:
pull_request:
branches: ["*"]
push:
branches: ["master"]
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
env:
FORCE_COLOR: 3
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Lint
run: pnpm lint && pnpm lint:ws
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm format
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm typecheck

193
.github/workflows/release-mobile.yml vendored Normal file
View File

@@ -0,0 +1,193 @@
name: release mobile app
on:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: write
jobs:
bump-version:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Automated Version Bump
uses: phips28/gh-action-bump-version@v11.0.0
with:
skip-tag: "true"
commit-message: "chore: bump mobile version to {{version}} [skip ci]"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PACKAGEJSON_DIR: "apps/expo"
build-android:
runs-on: ubuntu-latest
needs: [bump-version]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Pull version bump
run: git pull --all
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 9
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: "**/node_modules"
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Build Android app
run: cd apps/expo && pnpm apk
- name: Upload movie-web.apk as artifact
uses: actions/upload-artifact@v4
with:
name: apk
path: ./apps/expo/android/app/build/movie-web.apk
build-ios:
runs-on: macos-14
needs: [bump-version]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Pull version bump
run: git pull --all
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 9
run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
cache: "pnpm"
- name: Cache Node Modules
uses: actions/cache@v4
with:
path: "**/node_modules"
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install
- name: Cache Pods
uses: actions/cache@v4
with:
path: apps/expo/ios
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Build iOS app
run: cd apps/expo && pnpm ipa
- name: Upload movie-web.ipa as artifact
uses: actions/upload-artifact@v4
with:
name: ipa
path: ./apps/expo/ios/build/movie-web.ipa
release-app:
runs-on: ubuntu-latest
needs: [build-android, build-ios]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Pull version bump
run: git pull --all
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: Get package version
id: package-version
uses: martinbeentjes/npm-get-version-action@v1.3.1
with:
path: apps/expo
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ steps.package-version.outputs.current-version }}
files: |
movie-web.apk
movie-web.ipa
generate_release_notes: true
fail_on_unmatched_files: true
token: ${{ env.GITHUB_TOKEN }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
app-repo:
continue-on-error: true
runs-on: ubuntu-latest
needs: [build-ios, release-app]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Pull version bump
run: git pull --all
- name: Download IPA artifact
uses: actions/download-artifact@v4
with:
name: ipa
- name: Update app-repo.json
run: |
VERSION=$(jq -r '.version' apps/expo/package.json)
DATE=$(date -u +"%Y-%m-%d")
IPA_SIZE=$(ls -l movie-web.ipa | awk '{print $5}')
NEW_ENTRY=$(jq -n --arg version "$VERSION" --arg date "$DATE" --arg size "$IPA_SIZE" --arg downloadURL "https://github.com/movie-web/native-app/releases/download/v$VERSION/movie-web.ipa" '{version: $version, date: $date, size: ($size | tonumber), downloadURL: $downloadURL}')
jq --argjson newEntry "$NEW_ENTRY" '.apps[0].versions |= [$newEntry] + .' apps/expo/app-repo.json > temp.json && mv temp.json apps/expo/app-repo.json
- uses: EndBug/add-and-commit@v9
with:
default_author: github_actions
message: "chore: update app-repo.json"

52
.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# nitro
.nitro/
.output/
# expo
.expo/
dist/
expo-env.d.ts
apps/expo/.gitignore
ios/
android/
!modules/*/ios/
!modules/*/android/
# production
build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# turbo
.turbo
# tamagui
.tamagui

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
node-linker=hoisted

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.11

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

@@ -0,0 +1,9 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"expo.vscode-expo-tools",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors",
"bradlc.vscode-tailwindcss"
]
}

60
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,60 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Run iOS",
"request": "launch",
"runtimeArgs": [
"ios",
],
"cwd": "${workspaceFolder}/apps/expo",
"runtimeExecutable": "pnpm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
{
"name": "Run Android",
"request": "launch",
"runtimeArgs": [
"android",
],
"cwd": "${workspaceFolder}/apps/expo",
"runtimeExecutable": "pnpm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
{
"name": "Build IPA",
"request": "launch",
"runtimeArgs": [
"ipa",
],
"cwd": "${workspaceFolder}/apps/expo",
"runtimeExecutable": "pnpm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
{
"name": "Build APK",
"request": "launch",
"runtimeArgs": [
"apk",
],
"cwd": "${workspaceFolder}/apps/expo",
"runtimeExecutable": "pnpm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
]
}

31
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,31 @@
{
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"typescript.tsdk": "node_modules\\typescript\\lib",
"editor.formatOnSave": true,
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"eslint.format.enable": true,
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
],
"typescript.preferences.autoImportFileExcludePatterns": [
// Should import Text from UI components instead
"react-native/Libraries/Text/Text.d.ts"
],
"[github-actions-workflow]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
}

View File

@@ -1,2 +1,75 @@
# native-app # movie-web native-app
The native app version of movie-web
<!---
used a table bc this shit is annoying to resize to match, someone pls fix
--->
| iOS | Android |
|:--------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------------------------------------------------------------:|
| <a href="https://tinyurl.com/axk7vadz"><img src="https://i.imgur.com/46qhEAv.png" width="230"></a> | <a href="https://github.com/movie-web/native-app/releases/latest/download/movie-web.apk"><img src="https://i.imgur.com/WwPPgSZ.png" width="200"></a> |
## iOS Installation
> [!IMPORTANT]
> Sideloading with a paid certificate breaks a few features, most notably:
> - Downloads
> - Alternate App Icons
>
> We reccomend you use a local development certificate if you care about any of these.
- **AltStore:**
- Click the Add to AltStore badge to add the movie-web repository to AltStore.
- **Other:**
- Employ [Sideloadly](https://sideloadly.io/) or a sideloading method of your preference to install
the [ipa](https://github.com/movie-web/native-app/releases/latest/download/movie-web.ipa) directly.
## About
This repository uses [Turborepo](https://turborepo.org) and contains:
```text
.github
└─ workflows
└─ CI with pnpm cache setup
.vscode
└─ Recommended extensions and settings for VSCode users
apps
└─ expo
├─ Expo SDK 50
├─ React Native using React 18
├─ Navigation using Expo Router
└─ Styling with Tamagui
packages
├─ api
| └─ Typesafe API calls to the backend
├─ tmdb
| └─ Typesafe API calls to The Movie Database
└─ provider-utils
└─ Typesafe API calls to the video providers
tooling
├─ color
| └─ shared color palette
├─ eslint
| └─ shared, fine-grained, eslint presets
├─ prettier
| └─ shared prettier configuration
└─ typescript
└─ shared tsconfig you can extend from
```
## Getting started
### When it's time to add a new package
To add a new package, simply run `pnpm turbo gen init` in the monorepo root. This will prompt you for a package name as
well as if you want to install any dependencies to the new package (of course you can also do this yourself later).
The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary
configurations for tooling around your package such as formatting, linting and typechecking. When the package is
created, you're ready to go build out the package.
### References
This app is based on [create-t3-turbo](https://github.com/t3-oss/create-t3-turbo)
and [Turborepo](https://turborepo.org).

View File

@@ -0,0 +1,4 @@
{
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
}

View File

@@ -0,0 +1 @@
tamagui-web.css

18
apps/expo/app-repo.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "movie-web",
"apps": [
{
"name": "movie-web",
"bundleIdentifier": "dev.movieweb.app",
"category": "entertainment",
"developerName": "movie-web",
"iconURL": "https://github.com/movie-web/native-app/blob/master/apps/expo/assets/images/icon.png?raw=true",
"localizedDescription": "This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.",
"subtitle": "A small app for watching movies and shows easily",
"tintColor": "a87fd1",
"versions": [],
"appPermissions": {}
}
],
"news": []
}

100
apps/expo/app.config.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { ExpoConfig } from "expo/config";
import { version } from "./package.json";
import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement";
const defineConfig = (): ExpoConfig => ({
name: "movie-web",
slug: "mw-mobile",
scheme: "movieweb",
version,
icon: "./assets/images/icon.png",
userInterfaceStyle: "automatic",
splash: {
image: "./assets/images/splash.png",
resizeMode: "contain",
backgroundColor: "#000000",
},
updates: {
fallbackToCacheTimeout: 0,
},
assetBundlePatterns: ["**/*"],
ios: {
bundleIdentifier: "dev.movieweb.app",
supportsTablet: true,
requireFullScreen: true,
infoPlist: {
CFBundleName: "movie-web",
NSPhotoLibraryUsageDescription:
"This app saves videos to the photo library.",
NSAppTransportSecurity: {
NSAllowsArbitraryLoads: true,
},
},
},
android: {
package: "dev.movieweb.app",
permissions: ["WRITE_SETTINGS"],
},
web: {
favicon: "./assets/images/favicon.png",
bundler: "metro",
},
experiments: {
tsconfigPaths: true,
typedRoutes: true,
},
plugins: [
"expo-router",
[withRemoveiOSNotificationEntitlement as unknown as string],
[
"expo-screen-orientation",
{
initialOrientation: "PORTRAIT_UP",
},
],
[
"expo-build-properties",
{
android: {
minSdkVersion: 24,
packagingOptions: {
pickFirst: [
"lib/x86/libcrypto.so",
"lib/x86_64/libcrypto.so",
"lib/armeabi-v7a/libcrypto.so",
"lib/arm64-v8a/libcrypto.so",
],
},
},
},
],
[
"expo-alternate-app-icons",
[
"./assets/images/main.png",
"./assets/images/blue.png",
"./assets/images/gray.png",
"./assets/images/red.png",
"./assets/images/teal.png",
],
],
[
"expo-media-library",
{
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
isAccessMediaLocationEnabled: true,
},
],
[
"expo-pod-pinner",
{
targetName: "movieweb",
pods: [{ "OpenSSL-Universal": "1.1.2200" }],
},
],
],
});
export default defineConfig;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

21
apps/expo/babel.config.js Normal file
View File

@@ -0,0 +1,21 @@
/** @type {import("@babel/core").ConfigFunction} */
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
"@babel/plugin-transform-class-static-block",
"react-native-reanimated/plugin",
[
"module-resolver",
{
alias: {
crypto: "react-native-quick-crypto",
stream: "stream-browserify",
buffer: "@craftzdog/react-native-buffer",
},
},
],
],
};
};

31
apps/expo/eas.json Normal file
View File

@@ -0,0 +1,31 @@
{
"cli": {
"version": ">= 4.1.2"
},
"build": {
"base": {
"node": "18.16.1",
"ios": {
"resourceClass": "m-medium"
}
},
"development": {
"extends": "base",
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"extends": "base",
"distribution": "internal",
"ios": {
"simulator": true
}
},
"production": {
"extends": "base"
}
},
"submit": {
"production": {}
}
}

4
apps/expo/index.js Normal file
View File

@@ -0,0 +1,4 @@
import "expo-router/entry";
import "react-native-gesture-handler";
import "@react-native-anywhere/polyfill-base64";
import "text-encoding-polyfill";

60
apps/expo/metro.config.js Normal file
View File

@@ -0,0 +1,60 @@
// Learn more: https://docs.expo.dev/guides/monorepos/
const { getDefaultConfig } = require("expo/metro-config");
const { FileStore } = require("metro-cache");
const { withTamagui } = require("@tamagui/metro-plugin");
const path = require("path");
module.exports = withTurborepoManagedCache(
withMonorepoPaths(
withTamagui(
getDefaultConfig(__dirname, {
isCSSEnabled: true,
}),
{
components: ["tamagui"],
config: "./tamagui.config.ts",
},
),
),
);
/**
* Add the monorepo paths to the Metro config.
* This allows Metro to resolve modules from the monorepo.
*
* @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
* @param {import('expo/metro-config').MetroConfig} config
* @returns {import('expo/metro-config').MetroConfig}
*/
function withMonorepoPaths(config) {
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
// #1 - Watch all files in the monorepo
config.watchFolders = [workspaceRoot];
// #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
return config;
}
/**
* Move the Metro cache to the `node_modules/.cache/metro` folder.
* This repository configured Turborepo to use this cache location as well.
* If you have any environment variables, you can configure Turborepo to invalidate it when needed.
*
* @see https://turbo.build/repo/docs/reference/configuration#env
* @param {import('expo/metro-config').MetroConfig} config
* @returns {import('expo/metro-config').MetroConfig}
*/
function withTurborepoManagedCache(config) {
config.cacheStores = [
new FileStore({ root: path.join(__dirname, "node_modules/.cache/metro") }),
];
return config;
}

View File

@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["CheckIosCertificateModule"]
}
}

View File

@@ -0,0 +1,11 @@
import CheckIosCertificateModule from "./src/CheckIosCertificateModule";
interface CheckIosCertificateModule {
isDevelopmentProvisioningProfile(): boolean;
}
export function isDevelopmentProvisioningProfile(): boolean {
return (
CheckIosCertificateModule as CheckIosCertificateModule
).isDevelopmentProvisioningProfile();
}

View File

@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'CheckIosCertificate'
s.version = '1.0.0'
s.summary = 'Check if iOS certificate is Development or Production.'
s.description = 'Check if iOS certificate is Development or Production.'
s.author = 'castdrian'
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -0,0 +1,37 @@
import ExpoModulesCore
public class CheckIosCertificateModule: Module {
// Each module class must implement the definition function. The definition consists of components
// that describes the module's functionality and behavior.
// See https://docs.expo.dev/modules/module-api for more details about available components.
public func definition() -> ModuleDefinition {
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
// The module will be accessible from `requireNativeModule('CheckIosCertificate')` in JavaScript.
Name("CheckIosCertificate")
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
Function("isDevelopmentProvisioningProfile") { () -> Any in
#if targetEnvironment(simulator)
// Running on the Simulator
return true
#else
// Check for provisioning profile for non-Simulator execution
guard let filePath = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") else {
return false
}
let fileURL = URL(fileURLWithPath: filePath)
do {
let data = try String(contentsOf: fileURL, encoding: .ascii)
let cleared = data.components(separatedBy: .whitespacesAndNewlines).joined()
return cleared.contains("<key>get-task-allow</key><true/>")
} catch {
// Handling error if the file read fails
print("Error reading provisioning profile: \(error)")
return false
}
#endif
}
}
}

View File

@@ -0,0 +1,10 @@
import { UnavailabilityError } from "expo-modules-core";
export default {
isDevelopmentProvisioningProfile: () => {
throw new UnavailabilityError(
"CheckIosCertificate",
"isDevelopmentProvisioningProfile",
);
},
};

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from "expo-modules-core";
// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule("CheckIosCertificate");

109
apps/expo/package.json Normal file
View File

@@ -0,0 +1,109 @@
{
"name": "@movie-web/mobile",
"version": "0.0.1",
"private": true,
"main": "index.js",
"scripts": {
"clean": "git clean -xdf .expo .turbo node_modules",
"dev": "expo start",
"dev:android": "expo start -c --android",
"dev:ios": "expo start -c --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"apk": "expo prebuild --platform=android && cd android && ./gradlew assembleRelease && mv app/build/outputs/apk/release/app-release.apk app/build/movie-web.apk",
"ipa": "expo prebuild --platform=ios && cd ios && xcodebuild clean archive -workspace movieweb.xcworkspace -scheme movieweb -configuration Release -destination generic/platform=iOS -archivePath build/movieweb.xcarchive CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_ALLOWED=NO | xcbeautify && cd build/movieweb.xcarchive/Products && mv Applications Payload && zip -r movie-web.ipa Payload && mv movie-web.ipa ../..",
"ipa:sim": "expo prebuild --platform=ios && cd ios && xcodebuild clean archive -workspace movieweb.xcworkspace -scheme movieweb -configuration Release -destination \"generic/platform=iOS Simulator\" -archivePath build/movieweb.xcarchive CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_ALLOWED=NO | xcbeautify && cd build/movieweb.xcarchive/Products && mv Applications Payload && zip -r movie-web.ipa Payload && mv movie-web.ipa ../..",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/metro-config": "^0.17.3",
"@movie-web/api": "*",
"@movie-web/colors": "*",
"@movie-web/provider-utils": "*",
"@movie-web/tmdb": "*",
"@octokit/rest": "^20.0.2",
"@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0",
"@react-navigation/native": "^6.1.9",
"@salihgun/react-native-video-processor": "^0.3.1",
"@tamagui/animations-moti": "^1.94.0",
"@tamagui/babel-plugin": "^1.94.0",
"@tamagui/config": "^1.94.0",
"@tamagui/metro-plugin": "^1.94.0",
"@tamagui/toast": "1.94.0",
"@tanstack/react-query": "^5.22.2",
"burnt": "^0.12.2",
"class-variance-authority": "^0.7.0",
"expo": "~50.0.14",
"expo-alternate-app-icons": "^0.1.7",
"expo-application": "~5.8.3",
"expo-av": "~13.10.5",
"expo-brightness": "~11.8.0",
"expo-build-properties": "~0.11.1",
"expo-clipboard": "^5.0.1",
"expo-constants": "~15.4.5",
"expo-file-system": "~16.0.8",
"expo-haptics": "~12.8.1",
"expo-linear-gradient": "^12.7.2",
"expo-linking": "~6.2.2",
"expo-media-library": "~15.9.1",
"expo-navigation-bar": "^2.8.1",
"expo-network": "~5.8.0",
"expo-pod-pinner": "^1.0.1",
"expo-router": "~3.4.8",
"expo-screen-orientation": "~6.4.1",
"expo-splash-screen": "~0.26.4",
"expo-status-bar": "~1.11.1",
"expo-system-ui": "^2.9.3",
"expo-web-browser": "^12.8.2",
"ffmpeg-kit-react-native": "^6.0.2",
"immer": "^10.0.3",
"iso-639-1": "^3.1.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.73.6",
"react-native-context-menu-view": "^1.14.1",
"react-native-gesture-handler": "~2.14.1",
"react-native-markdown-display": "^7.0.2",
"react-native-mmkv": "^2.12.2",
"react-native-modal": "^13.0.1",
"react-native-quick-base64": "^2.0.8",
"react-native-quick-crypto": "^0.6.1",
"react-native-reanimated": "~3.6.2",
"react-native-safe-area-context": "~4.8.2",
"react-native-screens": "~3.29.0",
"react-native-svg": "14.1.0",
"react-native-web": "^0.19.10",
"subsrt-ts": "^2.1.2",
"tamagui": "^1.94.0",
"text-encoding-polyfill": "^0.6.7",
"zustand": "^4.4.7"
},
"devDependencies": {
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"@babel/runtime": "^7.23.9",
"@movie-web/eslint-config": "workspace:^0.2.0",
"@movie-web/prettier-config": "workspace:^0.1.0",
"@movie-web/tsconfig": "workspace:^0.1.0",
"@tanstack/eslint-plugin-query": "^5.20.1",
"@types/babel__core": "^7.20.5",
"@types/react": "^18.2.48",
"babel-plugin-module-resolver": "^5.0.0",
"eslint": "^8.56.0",
"prettier": "^3.1.1",
"typescript": "^5.4.3"
},
"eslintConfig": {
"root": true,
"extends": [
"@movie-web/eslint-config/base",
"@movie-web/eslint-config/react"
],
"ignorePatterns": [
"expo-plugins/**"
]
},
"prettier": "@movie-web/prettier-config"
}

View File

@@ -0,0 +1,65 @@
import { useEffect, useMemo } from "react";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { YStack } from "tamagui";
import { DownloadItem } from "~/components/DownloadItem";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store";
import { useDownloadHistoryStore } from "~/stores/settings";
export default function Page() {
const { tmdbId } = useLocalSearchParams();
const allDownloads = useDownloadHistoryStore((state) => state.downloads);
const resetVideo = usePlayerStore((state) => state.resetVideo);
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const router = useRouter();
const download = useMemo(() => {
return allDownloads.find((download) => download.media.tmdbId === tmdbId);
}, [allDownloads, tmdbId]);
useEffect(() => {
if (!download) router.back();
}, [download, router]);
const handlePress = (localPath?: string) => {
if (!localPath) return;
resetVideo();
setIsLocalFile(true);
setPlayerStatus(PlayerStatus.READY);
setVideoSrc({
uri: localPath,
});
router.push({
pathname: "/videoPlayer",
});
};
return (
<ScreenLayout showHeader={false}>
<Stack.Screen
options={{
title: download?.media.title ?? "Downloads",
}}
/>
<YStack gap="$4">
{download?.downloads.map((download) => {
return (
<DownloadItem
key={
download.media.type === "show"
? download.media.episode.tmdbId
: download.media.tmdbId
}
item={download}
onPress={() => handlePress(download.localPath)}
/>
);
})}
</YStack>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,14 @@
import { Stack } from "expo-router";
import { BrandPill } from "~/components/BrandPill";
export default function Layout() {
return (
<Stack
screenOptions={{
headerTransparent: true,
headerRight: BrandPill,
}}
/>
);
}

View File

@@ -0,0 +1,113 @@
import { Platform } from "react-native";
import * as Haptics from "expo-haptics";
import { Tabs } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useTheme, View } from "tamagui";
import { MovieWebSvg } from "~/components/Icon";
import SvgTabBarIcon from "~/components/SvgTabBarIcon";
import TabBarIcon from "~/components/TabBarIcon";
export default function TabLayout() {
const theme = useTheme();
return (
<Tabs
sceneContainerStyle={{
backgroundColor: theme.screenBackground.val,
}}
screenListeners={() => ({
tabPress: () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
focus: () => {
void ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
},
})}
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.tabBarIconFocused.val,
tabBarStyle: {
backgroundColor: theme.tabBarBackground.val,
borderTopColor: "transparent",
borderTopRightRadius: 20,
borderTopLeftRadius: 20,
paddingBottom: Platform.select({ ios: 100 }),
height: 80,
},
tabBarItemStyle: {
paddingVertical: 18,
height: 82,
},
tabBarLabelStyle: [
{
marginTop: 2,
},
],
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ focused }) => (
<TabBarIcon name="home" focused={focused} />
),
}}
/>
<Tabs.Screen
name="downloads"
options={{
title: "Downloads",
tabBarIcon: ({ focused }) => (
<TabBarIcon name="download" focused={focused} />
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: "Search",
tabBarLabel: "",
tabBarIcon: ({ focused }) => (
<View
top={2}
height={56}
width={56}
alignItems="center"
justifyContent="center"
overflow="hidden"
borderRadius={100}
backgroundColor={
focused ? theme.tabBarIconFocused : theme.tabBarIcon
}
>
<TabBarIcon name="search" color="#FFF" />
</View>
),
}}
/>
<Tabs.Screen
name="movie-web"
options={{
title: "movie-web",
tabBarIcon: ({ focused }) => (
<SvgTabBarIcon focused={focused}>
<MovieWebSvg />
</SvgTabBarIcon>
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ focused }) => (
<TabBarIcon name="cog" focused={focused} />
),
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,165 @@
import React from "react";
import { Alert, Platform } from "react-native";
import { useFocusEffect, useRouter } from "expo-router";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { isDevelopmentProvisioningProfile } from "modules/check-ios-certificate";
import { ScrollView, useTheme, YStack } from "tamagui";
import type { ScrapeMedia } from "@movie-web/provider-utils";
import { DownloadItem, ShowDownloadItem } from "~/components/DownloadItem";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { useDownloadManager } from "~/hooks/useDownloadManager";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store";
import { useDownloadHistoryStore } from "~/stores/settings";
const exampleMovieMedia: ScrapeMedia = {
type: "movie",
title: "Avengers: Endgame",
releaseYear: 2019,
imdbId: "tt4154796",
tmdbId: "299534",
};
const getExampleShowMedia = (seasonNumber: number, episodeNumber: number) =>
({
type: "show",
title: "Loki",
releaseYear: 2021,
imdbId: "tt9140554",
tmdbId: "84958",
season: {
number: seasonNumber,
tmdbId: seasonNumber.toString(),
},
episode: {
number: episodeNumber,
tmdbId: episodeNumber.toString(),
},
}) as const;
const TestDownloadButton = (props: {
media: ScrapeMedia;
type: "hls" | "mp4";
url: string;
}) => {
const { startDownload } = useDownloadManager();
const theme = useTheme();
return (
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name="download"
size={24}
color={theme.silver300.val}
/>
}
onPress={async () => {
await startDownload(props.url, props.type, props.media).catch(
console.error,
);
}}
>
test download
{props.type === "hls" ? " (hls)" : "(mp4)"}{" "}
{props.media.type === "show" ? "show" : "movie"}
</MWButton>
);
};
const DownloadsScreen: React.FC = () => {
const downloads = useDownloadHistoryStore((state) => state.downloads);
const resetVideo = usePlayerStore((state) => state.resetVideo);
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const router = useRouter();
useFocusEffect(
React.useCallback(() => {
if (Platform.OS === "ios" && !isDevelopmentProvisioningProfile()) {
Alert.alert(
"Production Certificate",
"Download functionality is not available when the application is signed with a distribution certificate.",
[
{
text: "OK",
onPress: () => router.back(),
},
],
);
}
}, [router]),
);
const handlePress = (localPath?: string) => {
if (!localPath) return;
resetVideo();
setIsLocalFile(true);
setPlayerStatus(PlayerStatus.READY);
setVideoSrc({
uri: localPath,
});
router.push({
pathname: "/videoPlayer",
});
};
return (
<ScreenLayout>
<YStack gap={2} style={{ padding: 10 }}>
<TestDownloadButton
media={exampleMovieMedia}
type="mp4"
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
/>
<TestDownloadButton
media={getExampleShowMedia(1, 1)}
type="mp4"
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
/>
<TestDownloadButton
media={getExampleShowMedia(1, 2)}
type="mp4"
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
/>
<TestDownloadButton
media={getExampleShowMedia(1, 1)}
type="hls"
url="http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8"
/>
</YStack>
<ScrollView
contentContainerStyle={{
gap: "$4",
}}
>
{downloads.map((download) => {
if (download.downloads.length === 0) return null;
if (download.media.type === "movie") {
return (
<DownloadItem
key={download.media.tmdbId}
item={download.downloads[0]!}
onPress={() => handlePress(download.downloads[0]!.localPath)}
/>
);
} else {
return (
<ShowDownloadItem
key={download.media.tmdbId}
download={download}
/>
);
}
})}
</ScrollView>
</ScreenLayout>
);
};
export default DownloadsScreen;

View File

@@ -0,0 +1,23 @@
import React from "react";
import { View } from "tamagui";
import { ItemListSection } from "~/components/item/ItemListSection";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
export default function HomeScreen() {
const { bookmarks } = useBookmarkStore();
const { watchHistory } = useWatchHistoryStore();
return (
<View style={{ flex: 1 }} flex={1}>
<ScreenLayout>
<ItemListSection title="Bookmarks" items={bookmarks} />
<ItemListSection
title="Continue Watching"
items={watchHistory.map((x) => x.item)}
/>
</ScreenLayout>
</View>
);
}

View File

@@ -0,0 +1,60 @@
import { Link } from "expo-router";
import { H3, H5, Paragraph, View } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
import { useAuthStore } from "~/stores/settings";
export default function MovieWebScreen() {
const { backendUrl, setBackendUrl } = useAuthStore();
return (
<ScreenLayout
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<MWCard bordered padded>
<MWCard.Header>
<H3 fontWeight="$bold" paddingBottom="$1">
Sync to the cloud
</H3>
<H5 color="$shade200" fontWeight="$semibold" paddingVertical="$3">
Share your watch progress between devices and keep them synced.
</H5>
<Paragraph color="$shade200">
First choose the backend you want to use. If you do not know what
this does, use the default and click on &apos;Get started&apos;.
</Paragraph>
</MWCard.Header>
<View padding="$4">
<MWInput
placeholder={backendUrl}
type="authentication"
value={backendUrl}
onChangeText={setBackendUrl}
/>
</View>
<MWCard.Footer padded justifyContent="center">
<Link
href={{
pathname: "/sync/trust/[url]",
params: { url: backendUrl },
}}
asChild
>
<MWButton type="purple" width="100%">
Get started
</MWButton>
</Link>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,159 @@
import React, { useEffect, useState } from "react";
import { Keyboard } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { View, ZStack } from "tamagui";
import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
import type { ItemData } from "~/components/item/item";
import Item from "~/components/item/item";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { SearchBar } from "~/components/ui/Searchbar";
export default function SearchScreen() {
const [query, setQuery] = useState("");
const translateY = useSharedValue(0);
const fadeAnim = useSharedValue(1);
const searchResultsOpacity = useSharedValue(0);
const searchResultsScale = useSharedValue(0.95);
const [searchResultsLoaded, setSearchResultsLoaded] = useState(false);
const { data } = useQuery({
queryKey: ["searchResults", query],
queryFn: () => fetchSearchResults(query),
});
useEffect(() => {
if (data && data.length > 0 && query) {
searchResultsOpacity.value = withTiming(1, { duration: 500 });
searchResultsScale.value = withTiming(1, { duration: 500 });
setSearchResultsLoaded(true);
} else if (!query) {
searchResultsOpacity.value = withTiming(0, { duration: 500 });
searchResultsScale.value = withTiming(0.95, { duration: 500 });
setSearchResultsLoaded(false);
}
}, [data, query, searchResultsOpacity, searchResultsScale]);
useEffect(() => {
const keyboardWillShowListener = Keyboard.addListener(
"keyboardWillShow",
(e) => {
translateY.value = withTiming(
-(e.endCoordinates.height - 100), // determines the height of the Searchbar above keyboard, use Platform.select to adjust value if needed
{
duration: e.duration ?? 250, // duration always returns 0 on Android, adjust value if needed
easing: Easing.out(Easing.ease),
},
);
},
);
const keyboardWillHideListener = Keyboard.addListener(
"keyboardWillHide",
() => {
translateY.value = withTiming(0, {
duration: 250,
easing: Easing.out(Easing.ease),
});
},
);
return () => {
keyboardWillShowListener.remove();
keyboardWillHideListener.remove();
};
}, [translateY]);
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateY: translateY.value }],
opacity: fadeAnim.value,
};
});
const searchResultsStyle = useAnimatedStyle(() => {
return {
opacity: searchResultsOpacity.value,
transform: [{ scale: searchResultsScale.value }],
};
});
const handleScrollBegin = () => {
fadeAnim.value = withTiming(0, {
duration: 100,
});
};
const handleScrollEnd = () => {
fadeAnim.value = withTiming(1, {
duration: 100,
});
};
return (
<ZStack flex={1}>
<ScreenLayout
onScrollBeginDrag={handleScrollBegin}
onMomentumScrollEnd={handleScrollEnd}
scrollEnabled={searchResultsLoaded ? true : false}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ flexGrow: 1 }}
>
<View>
<Animated.View style={[searchResultsStyle, { flex: 1 }]}>
<View flexDirection="row" flexWrap="wrap">
{data?.map((item, index) => (
<View
key={index}
paddingHorizontal={12}
paddingBottom={12}
width="50%"
>
<Item data={item} />
</View>
))}
</View>
</Animated.View>
</View>
</ScreenLayout>
<Animated.View
style={[
{
position: "absolute",
bottom: 5,
left: 0,
right: 0,
},
animatedStyle,
]}
>
<SearchBar onSearchChange={setQuery} />
</Animated.View>
</ZStack>
);
}
async function fetchSearchResults(query: string): Promise<ItemData[]> {
const results = await searchTitle(query);
return results.map((result) => ({
id: result.id.toString(),
title: result.media_type === "tv" ? result.name : result.title,
posterUrl: getMediaPoster(result.poster_path),
release_date: new Date(
result.media_type === "tv" ? result.first_air_date : result.release_date,
),
year: new Date(
result.media_type === "tv" ? result.first_air_date : result.release_date,
).getFullYear(),
type: result.media_type,
}));
}

View File

@@ -0,0 +1,525 @@
import type { SelectProps } from "tamagui";
import React, { useState } from "react";
import { Platform } from "react-native";
import Markdown from "react-native-markdown-display";
import * as Application from "expo-application";
import * as Brightness from "expo-brightness";
import * as FileSystem from "expo-file-system";
import * as WebBrowser from "expo-web-browser";
import {
FontAwesome,
MaterialCommunityIcons,
MaterialIcons,
} from "@expo/vector-icons";
import { useMutation } from "@tanstack/react-query";
import {
Adapt,
ScrollView,
Select,
Sheet,
Spinner,
Text,
useTheme,
View,
XStack,
YStack,
} from "tamagui";
import type { ThemeStoreOption } from "~/stores/theme";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWSelect } from "~/components/ui/Select";
import { MWSeparator } from "~/components/ui/Separator";
import { MWSwitch } from "~/components/ui/Switch";
import { useToast } from "~/hooks/useToast";
import { checkForUpdate } from "~/lib/update";
import {
useNetworkSettingsStore,
usePlayerSettingsStore,
} from "~/stores/settings";
import { useThemeStore } from "~/stores/theme";
const themeOptions: ThemeStoreOption[] = [
"main",
"blue",
"gray",
"red",
"teal",
];
const defaultQualityOptions = ["Highest", "Lowest"];
export default function SettingsScreen() {
const theme = useTheme();
const { gestureControls, setGestureControls, autoPlay, setAutoPlay } =
usePlayerSettingsStore();
const { allowMobileData, setAllowMobileData } = useNetworkSettingsStore();
const [showUpdateSheet, setShowUpdateSheet] = useState(false);
const [updateMarkdownContent, setUpdateMarkdownContent] = useState("");
const [downloadUrl, setDownloadUrl] = useState("");
const { showToast } = useToast();
const mutation = useMutation({
mutationKey: ["checkForUpdate"],
mutationFn: checkForUpdate,
onSuccess: (res) => {
if (res) {
setUpdateMarkdownContent(res.data.body!);
setDownloadUrl(
res.data.assets.find(
(asset) =>
asset.name ===
`movie-web.${Platform.select({ ios: "ipa", android: "apk" })}`,
)?.browser_download_url ?? "",
);
setShowUpdateSheet(true);
} else {
showToast("No updates available");
}
},
});
const handleGestureControlsToggle = async (isEnabled: boolean) => {
if (isEnabled) {
const { status } = await Brightness.requestPermissionsAsync();
if (status === Brightness.PermissionStatus.GRANTED) {
setGestureControls(isEnabled);
}
} else {
setGestureControls(isEnabled);
}
};
const clearCacheDirectory = async () => {
const cacheDirectory = `${FileSystem.cacheDirectory}movie-web`;
if (!cacheDirectory) return;
try {
await FileSystem.deleteAsync(cacheDirectory, { idempotent: true });
showToast("Cache cleared", {
burntOptions: { preset: "done" },
});
} catch (error) {
console.error("Error clearing cache directory:", error);
showToast("Error clearing cache", {
burntOptions: { preset: "error" },
});
}
};
return (
<ScreenLayout>
<View>
<YStack gap="$8">
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
Appearance
</Text>
<MWSeparator />
<YStack gap="$2">
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Theme
</Text>
<ThemeSelector />
</XStack>
</YStack>
</YStack>
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
Player
</Text>
<MWSeparator />
<YStack gap="$2">
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Gesture controls
</Text>
<MWSwitch
checked={gestureControls}
onCheckedChange={handleGestureControlsToggle}
>
<MWSwitch.Thumb />
</MWSwitch>
</XStack>
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Autoplay
</Text>
<MWSwitch checked={autoPlay} onCheckedChange={setAutoPlay}>
<MWSwitch.Thumb />
</MWSwitch>
</XStack>
</YStack>
</YStack>
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
Network
</Text>
<MWSeparator />
<YStack gap="$2">
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Default quality (Wi-Fi)
</Text>
<DefaultQualitySelector qualityType="wifi" />
</XStack>
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Default quality (Data)
</Text>
<DefaultQualitySelector qualityType="data" />
</XStack>
<XStack gap="$3" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Allow downloads on mobile data
</Text>
<MWSwitch
checked={allowMobileData}
onCheckedChange={setAllowMobileData}
>
<MWSwitch.Thumb />
</MWSwitch>
</XStack>
</YStack>
</YStack>
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
App
</Text>
<MWSeparator />
<YStack gap="$2">
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Version {Application.nativeApplicationVersion}
</Text>
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name={Platform.select({
ios: "apple",
android: "android",
})}
size={24}
color={theme.silver300.val}
/>
}
iconAfter={
<>{mutation.isPending && <Spinner color="$purple200" />}</>
}
disabled={mutation.isPending}
onPress={() => mutation.mutate()}
>
Update
</MWButton>
</XStack>
<XStack gap="$4" alignItems="center">
<Text fontWeight="$semibold" flexGrow={1}>
Storage
</Text>
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name="broom"
size={24}
color={theme.silver300.val}
/>
}
onPress={() => clearCacheDirectory()}
>
Clear Cache
</MWButton>
</XStack>
</YStack>
</YStack>
</YStack>
</View>
<UpdateSheet
markdownContent={updateMarkdownContent}
open={showUpdateSheet}
setShowUpdateSheet={setShowUpdateSheet}
downloadUrl={downloadUrl}
/>
</ScreenLayout>
);
}
export function UpdateSheet({
markdownContent,
open,
setShowUpdateSheet,
downloadUrl,
}: {
markdownContent: string;
open: boolean;
setShowUpdateSheet: (value: boolean) => void;
downloadUrl: string;
}) {
const theme = useTheme();
return (
<Sheet
modal
open={open}
onOpenChange={setShowUpdateSheet}
dismissOnSnapToBottom
dismissOnOverlayPress
animationConfig={{
type: "spring",
damping: 20,
mass: 1.2,
stiffness: 250,
}}
snapPoints={[35]}
>
<Sheet.Handle backgroundColor="$sheetHandle" />
<Sheet.Frame
backgroundColor="$sheetBackground"
padding="$4"
alignItems="center"
justifyContent="center"
>
<ScrollView>
<Markdown
style={{
text: {
color: "white",
},
}}
>
{markdownContent}
</Markdown>
</ScrollView>
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name={Platform.select({ ios: "apple", android: "android" })}
size={24}
color={theme.silver300.val}
/>
}
onPress={() => WebBrowser.openBrowserAsync(downloadUrl)}
>
Download
</MWButton>
</Sheet.Frame>
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.8)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
</Sheet>
);
}
export function ThemeSelector(props: SelectProps) {
const theme = useTheme();
const themeStore = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);
return (
<MWSelect
value={themeStore}
onValueChange={setTheme}
disablePreventBodyScroll
{...props}
>
<MWSelect.Trigger
maxWidth="$12"
iconAfter={
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
}
>
<Select.Value fontWeight="$semibold" textTransform="capitalize" />
</MWSelect.Trigger>
<Adapt platform="native">
<Sheet
modal
dismissOnSnapToBottom
dismissOnOverlayPress
animationConfig={{
type: "spring",
damping: 20,
mass: 1.2,
stiffness: 250,
}}
snapPoints={[35]}
>
<Sheet.Handle backgroundColor="$sheetHandle" />
<Sheet.Frame
backgroundColor="$sheetBackground"
padding="$4"
alignItems="center"
justifyContent="center"
>
<Adapt.Contents />
</Sheet.Frame>
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.8)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
</Sheet>
</Adapt>
<Select.Content>
<Select.Viewport
animation="static"
animateOnly={["transform", "opacity"]}
enterStyle={{ o: 0, y: -10 }}
exitStyle={{ o: 0, y: 10 }}
>
{themeOptions.map((item, i) => (
<Select.Item
index={i}
key={item}
value={item}
backgroundColor="$sheetItemBackground"
borderTopRightRadius={i === 0 ? "$8" : 0}
borderTopLeftRadius={i === 0 ? "$8" : 0}
borderBottomRightRadius={i === themeOptions.length - 1 ? "$8" : 0}
borderBottomLeftRadius={i === themeOptions.length - 1 ? "$8" : 0}
>
<Select.ItemText
textTransform="capitalize"
fontWeight="$semibold"
>
{item}
</Select.ItemText>
<Select.ItemIndicator ml="auto">
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
</Select.ItemIndicator>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</MWSelect>
);
}
interface DefaultQualitySelectorProps extends SelectProps {
qualityType: "wifi" | "data";
}
export function DefaultQualitySelector(props: DefaultQualitySelectorProps) {
const theme = useTheme();
const {
wifiDefaultQuality,
mobileDataDefaultQuality,
setWifiDefaultQuality,
setMobileDataDefaultQuality,
} = useNetworkSettingsStore();
return (
<MWSelect
value={
props.qualityType === "wifi"
? wifiDefaultQuality
: mobileDataDefaultQuality
}
onValueChange={
props.qualityType === "wifi"
? setWifiDefaultQuality
: setMobileDataDefaultQuality
}
disablePreventBodyScroll
{...props}
>
<MWSelect.Trigger
maxWidth="$10"
iconAfter={
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
}
>
<Select.Value fontWeight="$semibold" textTransform="capitalize" />
</MWSelect.Trigger>
<Adapt platform="native">
<Sheet
modal
dismissOnSnapToBottom
dismissOnOverlayPress
animationConfig={{
type: "spring",
damping: 20,
mass: 1.2,
stiffness: 250,
}}
snapPoints={[35]}
>
<Sheet.Handle backgroundColor="$sheetHandle" />
<Sheet.Frame
backgroundColor="$sheetBackground"
padding="$4"
alignItems="center"
justifyContent="center"
>
<Adapt.Contents />
</Sheet.Frame>
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.8)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
</Sheet>
</Adapt>
<Select.Content>
<Select.Viewport
animation="static"
animateOnly={["transform", "opacity"]}
enterStyle={{ o: 0, y: -10 }}
exitStyle={{ o: 0, y: 10 }}
>
{defaultQualityOptions.map((item, i) => (
<Select.Item
index={i}
key={item}
value={item}
backgroundColor="$sheetItemBackground"
borderTopRightRadius={i === 0 ? "$8" : 0}
borderTopLeftRadius={i === 0 ? "$8" : 0}
borderBottomRightRadius={
i === defaultQualityOptions.length - 1 ? "$8" : 0
}
borderBottomLeftRadius={
i === defaultQualityOptions.length - 1 ? "$8" : 0
}
>
<Select.ItemText
textTransform="capitalize"
fontWeight="$semibold"
>
{item}
</Select.ItemText>
<Select.ItemIndicator ml="auto">
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
</Select.ItemIndicator>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</MWSelect>
);
}

View File

@@ -0,0 +1,46 @@
import { ScrollViewStyleReset } from "expo-router/html";
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View File

@@ -0,0 +1,25 @@
import * as Linking from "expo-linking";
import { Link, Stack } from "expo-router";
import { Text, View } from "tamagui";
export default function NotFoundScreen() {
if (Linking.useURL()) return null;
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<View flex={1} alignItems="center" justifyContent="center" padding={5}>
<Text fontWeight="bold">This screen doesn&apos;t exist.</Text>
<Link
href="/"
style={{
marginTop: 16,
paddingVertical: 16,
}}
>
<Text color="skyblue">Go to home screen!</Text>
</Link>
</View>
</>
);
}

View File

@@ -0,0 +1,118 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useFonts } from "expo-font";
import { SplashScreen, Stack } from "expo-router";
import FontAwesome from "@expo/vector-icons/FontAwesome";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { ToastProvider, ToastViewport } from "@tamagui/toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TamaguiProvider, Theme, useTheme } from "tamagui";
import tamaguiConfig from "tamagui.config";
import { useThemeStore } from "~/stores/theme";
// @ts-expect-error - Without named import it causes an infinite loop
import _styles from "../../tamagui-web.css";
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from "expo-router";
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: "(tabs)",
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync().catch(() => {
/* reloading the app might trigger this, so it's safe to ignore */
});
const queryClient = new QueryClient();
export default function RootLayout() {
const [loaded, error] = useFonts({
OpenSansRegular: require("../../assets/fonts/OpenSans-Regular.ttf"),
OpenSansLight: require("../../assets/fonts/OpenSans-Light.ttf"),
OpenSansMedium: require("../../assets/fonts/OpenSans-Medium.ttf"),
OpenSansBold: require("../../assets/fonts/OpenSans-Bold.ttf"),
OpenSansSemiBold: require("../../assets/fonts/OpenSans-SemiBold.ttf"),
OpenSansExtra: require("../../assets/fonts/OpenSans-ExtraBold.ttf"),
...FontAwesome.font,
});
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync().catch(() => {
/* reloading the app might trigger this, so it's safe to ignore */
});
}
}, [loaded]);
if (!loaded) {
return null;
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<RootLayoutNav />
</GestureHandlerRootView>
);
}
function ScreenStacks() {
const theme = useTheme();
return (
<Stack
screenOptions={{
autoHideHomeIndicator: true,
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
headerShown: false,
contentStyle: {
backgroundColor: theme.screenBackground.val,
},
}}
>
<Stack.Screen
name="(tabs)"
options={{
headerShown: false,
autoHideHomeIndicator: true,
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
}}
/>
</Stack>
);
}
function RootLayoutNav() {
const themeStore = useThemeStore((s) => s.theme);
return (
<QueryClientProvider client={queryClient}>
<TamaguiProvider config={tamaguiConfig} defaultTheme="main">
<ToastProvider>
<ThemeProvider value={DarkTheme}>
<Theme name={themeStore}>
<ScreenStacks />
</Theme>
</ThemeProvider>
<ToastViewport />
</ToastProvider>
</TamaguiProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,14 @@
import { Stack } from "expo-router";
import { BrandPill } from "~/components/BrandPill";
export default function Layout() {
return (
<Stack
screenOptions={{
headerTransparent: true,
headerRight: BrandPill,
}}
/>
);
}

View File

@@ -0,0 +1,79 @@
import { Stack } from "expo-router";
import { H4, Label, Paragraph, Text, YStack } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
export default function Page() {
return (
<ScreenLayout
showHeader={false}
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Stack.Screen
options={{
title: "",
}}
/>
<MWCard bordered padded>
<MWCard.Header>
<H4 fontWeight="$bold" textAlign="center">
Login to your account
</H4>
<Paragraph
color="$ash50"
textAlign="center"
fontWeight="$semibold"
paddingVertical="$4"
>
Please enter your passphrase to login to your account
</Paragraph>
</MWCard.Header>
<YStack paddingBottom="$5">
<YStack gap="$1">
<Label fontWeight="$bold">12-Word passphrase</Label>
<MWInput
type="authentication"
placeholder="Passphrase"
secureTextEntry
autoCorrect={false}
/>
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">Device name</Label>
<MWInput
type="authentication"
placeholder="Personal phone"
autoCorrect={false}
/>
</YStack>
</YStack>
<MWCard.Footer
padded
justifyContent="center"
flexDirection="column"
gap="$4"
>
<MWButton type="purple">Login</MWButton>
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
Don&apos;t have an account yet?{"\n"}
<Text color="$purple100" fontWeight="$bold">
Create an account.
</Text>
</Paragraph>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from "react";
import { Link, Stack } from "expo-router";
import { FontAwesome6, Ionicons } from "@expo/vector-icons";
import { Circle, H4, Label, Paragraph, View, XStack, YStack } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
const colors = ["#0A54FF", "#CF2E68", "#F9DD7F", "#7652DD", "#2ECFA8"] as const;
function ColorPicker(props: {
value: (typeof colors)[number];
onInput: (v: (typeof colors)[number]) => void;
}) {
return (
<XStack gap="$2">
{colors.map((color) => {
return (
<View
onPress={() => props.onInput(color)}
flexGrow={1}
height="$4"
borderRadius="$4"
justifyContent="center"
alignItems="center"
backgroundColor={color}
key={color}
>
{props.value === color ? (
<Ionicons name="checkmark-circle" size={24} color="white" />
) : null}
</View>
);
})}
</XStack>
);
}
const icons = [
"user-group",
"couch",
"mobile-screen",
"ticket",
"handcuffs",
] as const;
function UserIconPicker(props: {
value: (typeof icons)[number];
onInput: (v: (typeof icons)[number]) => void;
}) {
return (
<XStack gap="$2">
{icons.map((icon) => {
return (
<View
flexGrow={1}
height="$4"
borderRadius="$4"
justifyContent="center"
alignItems="center"
backgroundColor={props.value === icon ? "$purple400" : "$shade400"}
borderColor={props.value === icon ? "$purple200" : "$shade400"}
borderWidth={1}
key={icon}
onPress={() => props.onInput(icon)}
>
<FontAwesome6 name={icon} size={24} color="white" />
</View>
);
})}
</XStack>
);
}
interface AvatarProps {
colorA: string;
colorB: string;
icon: (typeof icons)[number];
}
export function Avatar(props: AvatarProps) {
return (
<Circle
backgroundColor={props.colorA}
height="$6"
width="$6"
justifyContent="center"
alignItems="center"
>
<LinearGradient
colors={[props.colorA, props.colorB]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
borderRadius="$12"
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
>
<FontAwesome6 name={props.icon} size={24} color="white" />
</LinearGradient>
</Circle>
);
}
export default function Page() {
const [color, setColor] = useState<(typeof colors)[number]>(colors[0]);
const [color2, setColor2] = useState<(typeof colors)[number]>(colors[0]);
const [icon, setIcon] = useState<(typeof icons)[number]>(icons[0]);
return (
<ScreenLayout
showHeader={false}
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Stack.Screen
options={{
title: "",
}}
/>
<MWCard bordered padded>
<MWCard.Header>
<View alignItems="center" marginBottom="$3">
<Avatar colorA={color} colorB={color2} icon={icon} />
</View>
<H4 fontWeight="$bold" textAlign="center">
Account information
</H4>
<Paragraph
color="$shade200"
textAlign="center"
fontWeight="$normal"
paddingTop="$4"
>
Enter a name for your device and pick colours and a user icon of
your choosing
</Paragraph>
</MWCard.Header>
<YStack paddingBottom="$5">
<YStack gap="$1">
<Label fontWeight="$bold">Device name</Label>
<MWInput
type="authentication"
placeholder="Passphrase"
secureTextEntry
autoCorrect={false}
/>
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">Profile color one</Label>
<ColorPicker value={color} onInput={(color) => setColor(color)} />
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">Profile color two</Label>
<ColorPicker value={color2} onInput={(color) => setColor2(color)} />
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">User icon</Label>
<UserIconPicker value={icon} onInput={(icon) => setIcon(icon)} />
</YStack>
</YStack>
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
<Link
href={{
pathname: "/sync/register/confirm",
}}
replace
asChild
>
<MWButton type="purple" width="100%">
Next
</MWButton>
</Link>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,68 @@
import { Link, Stack } from "expo-router";
import { H4, Label, Paragraph, YStack } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
export default function Page() {
return (
<ScreenLayout
showHeader={false}
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Stack.Screen
options={{
title: "",
}}
/>
<MWCard bordered padded>
<MWCard.Header>
<H4 fontWeight="$bold" textAlign="center">
Confirm your passphrase
</H4>
<Paragraph
color="$shade200"
textAlign="center"
fontWeight="$normal"
paddingTop="$4"
>
Please enter your passphrase from earlier to confirm you have saved
it and to create your account
</Paragraph>
</MWCard.Header>
<YStack paddingBottom="$5">
<YStack gap="$1">
<Label fontWeight="$bold">12-Word passphrase</Label>
<MWInput
type="authentication"
placeholder="Passphrase"
secureTextEntry
autoCorrect={false}
/>
</YStack>
</YStack>
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
<Link
href={{
pathname: "/(tabs)/movie-web",
}}
replace
asChild
>
<MWButton type="purple">Create account</MWButton>
</Link>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,132 @@
import { TouchableOpacity } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { Link, Stack } from "expo-router";
import { Feather } from "@expo/vector-icons";
import { H4, Paragraph, Text, useTheme, View, XStack, YStack } from "tamagui";
import { genMnemonic } from "@movie-web/api";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
function PassphraseWord({ word }: { word: string }) {
return (
<View
width="$10"
borderRadius="$4"
paddingHorizontal="$4"
paddingVertical="$3"
alignItems="center"
justifyContent="center"
backgroundColor="$shade400"
>
<Text fontWeight="$bold">{word}</Text>
</View>
);
}
export default function Page() {
const theme = useTheme();
const words = genMnemonic().split(" ");
return (
<ScreenLayout
showHeader={false}
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Stack.Screen
options={{
title: "",
}}
/>
<MWCard bordered padded>
<MWCard.Header>
<H4 fontWeight="$bold" textAlign="center">
Your passphrase
</H4>
<Paragraph
color="$shade200"
textAlign="center"
fontWeight="$normal"
paddingTop="$4"
>
Your passphrase acts as your username and password. Make sure to
keep it safe as you will need to enter it to login to your account
</Paragraph>
</MWCard.Header>
<YStack
borderRadius="$4"
borderColor="$shade200"
borderWidth="$0.5"
marginBottom="$4"
>
<XStack
gap="$1"
borderBottomWidth="$0.5"
borderColor="$shade200"
paddingVertical="$2"
paddingHorizontal="$4"
>
<Text fontWeight="$bold" flexGrow={1}>
Passphrase
</Text>
<TouchableOpacity
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
onPress={async () => {
await Clipboard.setStringAsync(words.join(""));
}}
>
<Feather name="copy" size={18} color={theme.shade200.val} />
<Text color="$shade200" fontWeight="$bold">
Copy
</Text>
</TouchableOpacity>
</XStack>
<View
flexWrap="wrap"
flexDirection="row"
gap="$4"
alignItems="center"
justifyContent="center"
padding="$3"
>
{words.map((word, index) => (
<PassphraseWord key={index} word={word} />
))}
</View>
</YStack>
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
<Link
href={{
pathname: "/sync/register/account",
}}
asChild
>
<MWButton type="purple">I have saved my passphrase</MWButton>
</Link>
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
Already have an account?{"\n"}
<Text color="$purple100" fontWeight="$bold">
<Link href="/sync/login">Login here</Link>
</Text>
</Paragraph>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,114 @@
import { Link, Stack, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { H4, Paragraph, Text, View } from "tamagui";
import { getBackendMeta } from "@movie-web/api";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
export default function Page() {
const { url } = useLocalSearchParams<{ url: string }>();
const meta = useQuery({
queryKey: ["backendMeta", url],
queryFn: () => getBackendMeta(url as unknown as string),
});
return (
<ScreenLayout
showHeader={false}
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Stack.Screen
options={{
title: "",
}}
/>
<MWCard bordered padded>
<MWCard.Header padded>
<H4 fontWeight="$bold" textAlign="center">
Do you trust this server?
</H4>
<Paragraph
color="$ash50"
textAlign="center"
fontWeight="$semibold"
paddingVertical="$4"
>
{meta.isLoading && "Loading..."}
{meta.isError && "Error loading metadata"}
{meta.isSuccess && (
<>
You are connecting to{" "}
<Text
fontWeight="$bold"
color="white"
textDecorationLine="underline"
>
{url}
</Text>
. Please confirm you trust it before making an account.
</>
)}
</Paragraph>
</MWCard.Header>
{meta.isSuccess && (
<View
borderColor="$shade200"
borderWidth="$0.5"
borderRadius="$8"
paddingHorizontal="$5"
paddingVertical="$4"
width="90%"
alignSelf="center"
>
<Text
fontWeight="$bold"
paddingBottom="$1"
textAlign="center"
fontSize="$4"
>
{meta.data.name}
</Text>
<Paragraph color="$ash50" textAlign="center">
{meta.data.description}
</Paragraph>
</View>
)}
<MWCard.Footer
padded
justifyContent="center"
flexDirection="column"
gap="$4"
>
<Link
href={{
pathname: "/sync/register",
}}
asChild
>
<MWButton type="purple">I trust this server</MWButton>
</Link>
<Link
href={{
pathname: "/(tabs)/",
}}
replace
asChild
>
<MWButton type="cancel">Go back</MWButton>
</Link>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,45 @@
import { useLocalSearchParams } from "expo-router";
import type { ScrapeMedia } from "@movie-web/provider-utils";
import type { ItemData } from "~/components/item/item";
import { ScraperProcess } from "~/components/player/ScraperProcess";
import { VideoPlayer } from "~/components/player/VideoPlayer";
import { usePlayer } from "~/hooks/player/usePlayer";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store";
export default function VideoPlayerWrapper() {
const playerStatus = usePlayerStore((state) => state.interface.playerStatus);
const { presentFullscreenPlayer } = usePlayer();
const params = useLocalSearchParams();
let data;
if ("data" in params) {
if (typeof params.data === "string") {
data = JSON.parse(params.data) as Partial<ItemData>;
} else {
data = undefined;
}
} else {
data = params as Partial<ItemData>;
}
const media = params.media
? (JSON.parse(params.media as string) as ScrapeMedia)
: undefined;
const download = params.download === "true";
void presentFullscreenPlayer();
if (download) {
return <ScraperProcess data={data} download />;
}
if (playerStatus === PlayerStatus.SCRAPING) {
return <ScraperProcess data={data} media={media} />;
}
if (playerStatus === PlayerStatus.READY) {
return <VideoPlayer />;
}
}

View File

@@ -0,0 +1,35 @@
import * as Haptics from "expo-haptics";
import { Text, useTheme, View } from "tamagui";
import { MovieWebSvg } from "./Icon";
export function BrandPill() {
const theme = useTheme();
return (
<View
flexDirection="row"
alignItems="center"
justifyContent="center"
paddingHorizontal="$3"
paddingVertical="$2.5"
gap="$2.5"
opacity={0.8}
backgroundColor="$pillBackground"
borderRadius={24}
pressStyle={{
opacity: 1,
scale: 1.05,
}}
onLongPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)}
>
<MovieWebSvg
fillColor={theme.tabBarIconFocused.val}
width={20}
height={20}
/>
<Text fontSize="$6" fontWeight="$bold">
movie-web
</Text>
</View>
);
}

View File

@@ -0,0 +1,201 @@
import type { NativeSyntheticEvent } from "react-native";
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
import React from "react";
import ContextMenu from "react-native-context-menu-view";
import { TouchableOpacity } from "react-native-gesture-handler";
import { useRouter } from "expo-router";
import { Image, Text, View, XStack, YStack } from "tamagui";
import type { Download, DownloadContent } from "~/hooks/useDownloadManager";
import { useDownloadManager } from "~/hooks/useDownloadManager";
import { mapSeasonAndEpisodeNumberToText } from "./player/utils";
import { MWProgress } from "./ui/Progress";
import { FlashingText } from "./ui/Text";
export interface DownloadItemProps {
item: Download;
onPress: (localPath?: string) => void;
}
enum ContextMenuActions {
Cancel = "Cancel",
Remove = "Remove",
}
const statusToTextMap: Record<Download["status"], string> = {
downloading: "Downloading",
finished: "Finished",
error: "Error",
merging: "Merging",
cancelled: "Cancelled",
importing: "Importing",
};
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
};
export function DownloadItem(props: DownloadItemProps) {
const percentage = props.item.progress * 100;
const formattedFileSize = formatBytes(props.item.fileSize);
const formattedDownloaded = formatBytes(props.item.downloaded);
const { removeDownload, cancelDownload } = useDownloadManager();
const contextMenuActions = [
{
title: ContextMenuActions.Remove,
},
...(props.item.status !== "finished"
? [{ title: ContextMenuActions.Cancel }]
: []),
];
const onContextMenuPress = (
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
) => {
if (e.nativeEvent.name === ContextMenuActions.Cancel) {
void cancelDownload(props.item);
} else if (e.nativeEvent.name === ContextMenuActions.Remove) {
removeDownload(props.item);
}
};
const isInProgress = !(
props.item.status === "finished" ||
props.item.status === "error" ||
props.item.status === "cancelled"
);
return (
<ContextMenu
actions={contextMenuActions}
onPress={onContextMenuPress}
previewBackgroundColor="transparent"
>
<TouchableOpacity
onPress={() => props.onPress(props.item.localPath)}
onLongPress={() => {
return;
}}
activeOpacity={0.7}
>
<XStack gap="$4" alignItems="center">
<View
aspectRatio={9 / 14}
width={70}
maxHeight={180}
overflow="hidden"
borderRadius="$2"
>
<Image
source={{
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
}}
width="100%"
height="100%"
/>
</View>
<YStack gap="$2" flex={1}>
<XStack justifyContent="space-between" alignItems="center">
<Text
fontWeight="$bold"
numberOfLines={1}
ellipsizeMode="tail"
flex={1}
>
{props.item.media.type === "show" &&
`${mapSeasonAndEpisodeNumberToText(
props.item.media.season.number,
props.item.media.episode.number,
)} `}
{props.item.media.title}
</Text>
{props.item.type !== "hls" && (
<Text fontSize="$2" color="gray">
{props.item.speed.toFixed(2)} MB/s
</Text>
)}
</XStack>
<MWProgress value={percentage} height={10} maxWidth="100%">
<MWProgress.Indicator />
</MWProgress>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" color="gray">
{props.item.type === "hls"
? `${percentage.toFixed()}% - ${props.item.downloaded} of ${props.item.fileSize} segments`
: `${percentage.toFixed()}% - ${formattedDownloaded} of ${formattedFileSize}`}
</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<FlashingText
isInProgress={isInProgress}
style={{
fontSize: 12,
color: "gray",
}}
>
{statusToTextMap[props.item.status]}
</FlashingText>
</View>
</XStack>
</YStack>
</XStack>
</TouchableOpacity>
</ContextMenu>
);
}
export function ShowDownloadItem({ download }: { download: DownloadContent }) {
const router = useRouter();
return (
<TouchableOpacity
onPress={() =>
router.push({
pathname: "/(downloads)/[tmdbId]",
params: { tmdbId: download.media.tmdbId },
})
}
activeOpacity={0.7}
>
<XStack gap="$4" alignItems="center">
<View
aspectRatio={9 / 14}
width={70}
maxHeight={180}
overflow="hidden"
borderRadius="$2"
>
<Image
source={{
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
}}
width="100%"
height="100%"
/>
</View>
<YStack gap="$2">
<YStack gap="$1">
<Text fontWeight="$bold" ellipse flexGrow={1} fontSize="$5">
{download.media.title}
</Text>
<Text fontSize="$2">
{download.downloads.length} Episode
{download.downloads.length > 1 ? "s" : ""} |{" "}
{formatBytes(
download.downloads.reduce(
(acc, curr) => acc + curr.fileSize,
0,
),
)}
</Text>
</YStack>
</YStack>
</XStack>
</TouchableOpacity>
);
}

View File

@@ -0,0 +1,15 @@
import { Image } from "tamagui";
// TODO: Improve flag icons. This is incomplete.
export function FlagIcon({ languageCode }: { languageCode: string }) {
return (
<Image
source={{
uri: `https://flagcdn.com/w80/${languageCode.toLowerCase()}.png`,
}}
width="100%"
height="100%"
resizeMode="contain"
/>
);
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import Svg, { G, Path } from "react-native-svg";
export const MovieWebSvg = ({
fillColor,
height = 24,
width = 24,
}: {
fillColor?: string;
height?: number;
width?: number;
}) => {
const svgPath =
"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";
return (
<Svg
width={width}
height={height}
viewBox="0 0 20.927 20.927"
fill={fillColor}
>
<G transform="translate(10.018 -7.425) rotate(45)">
<Path d={svgPath} />
</G>
</Svg>
);
};

View File

@@ -0,0 +1,23 @@
import React from "react";
import { useTheme } from "tamagui";
interface SvgTabBarIconProps {
focused?: boolean;
children: React.ReactElement;
}
export default function SvgTabBarIcon({
focused,
children,
}: SvgTabBarIconProps) {
const theme = useTheme();
const fillColor = focused
? theme.tabBarIconFocused.val
: theme.tabBarIcon.val;
if (React.isValidElement(children)) {
return React.cloneElement(children, { fillColor } as React.Attributes);
}
return null;
}

View File

@@ -0,0 +1,12 @@
import { FontAwesome } from "@expo/vector-icons";
import { useTheme } from "tamagui";
type Props = {
focused?: boolean;
} & React.ComponentProps<typeof FontAwesome>;
export default function TabBarIcon({ focused, ...rest }: Props) {
const theme = useTheme();
const color = focused ? theme.tabBarIconFocused.val : theme.tabBarIcon.val;
return <FontAwesome color={color} size={24} {...rest} />;
}

View File

@@ -0,0 +1,33 @@
import React from "react";
import { Dimensions } from "react-native";
import { ScrollView, Text, View } from "tamagui";
import type { ItemData } from "~/components/item/item";
import Item from "~/components/item/item";
const padding = 20;
const screenWidth = Dimensions.get("window").width;
const itemWidth = screenWidth / 2.3 - padding;
export const ItemListSection = ({
title,
items,
}: {
title: string;
items: ItemData[];
}) => {
return (
<View>
<Text marginBottom={8} marginTop={16} fontWeight="bold" fontSize="$8">
{title}
</Text>
<ScrollView horizontal={true} showsHorizontalScrollIndicator={false}>
{items.map((item, index) => (
<View key={index} width={itemWidth} paddingBottom={padding}>
<Item data={item} />
</View>
))}
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,152 @@
import type { NativeSyntheticEvent } from "react-native";
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
import { useCallback } from "react";
import { Keyboard, TouchableOpacity } from "react-native";
import ContextMenu from "react-native-context-menu-view";
import { useRouter } from "expo-router";
import { Image, Text, View } from "tamagui";
import { useToast } from "~/hooks/useToast";
import { usePlayerStore } from "~/stores/player/store";
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
export interface ItemData {
id: string;
title: string;
type: "movie" | "tv";
season?: number;
episode?: number;
year: number;
release_date?: Date;
posterUrl: string;
}
enum ContextMenuActions {
Bookmark = "Bookmark",
RemoveBookmark = "Remove Bookmark",
Download = "Download",
RemoveWatchHistoryItem = "Remove from Continue Watching",
}
function checkReleased(media: ItemData): boolean {
const isReleasedYear = Boolean(
media.year && media.year <= new Date().getFullYear(),
);
const isReleasedDate = Boolean(
media.release_date && media.release_date <= new Date(),
);
// If the media has a release date, use that, otherwise use the year
const isReleased = media.release_date ? isReleasedDate : isReleasedYear;
return isReleased;
}
export default function Item({ data }: { data: ItemData }) {
const resetVideo = usePlayerStore((state) => state.resetVideo);
const router = useRouter();
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
const { hasWatchHistoryItem, removeFromWatchHistory } =
useWatchHistoryStore();
const { showToast } = useToast();
const { title, type, year, posterUrl } = data;
const isReleased = useCallback(() => checkReleased(data), [data]);
const handlePress = () => {
if (!isReleased()) {
showToast("This media is not released yet", {
burntOptions: { preset: "error" },
});
return;
}
resetVideo();
Keyboard.dismiss();
router.push({
pathname: "/videoPlayer",
params: { data: JSON.stringify(data) },
});
};
const contextMenuActions = [
{
title: isBookmarked(data)
? ContextMenuActions.RemoveBookmark
: ContextMenuActions.Bookmark,
},
...(type === "movie" ? [{ title: ContextMenuActions.Download }] : []),
...(hasWatchHistoryItem(data)
? [{ title: ContextMenuActions.RemoveWatchHistoryItem }]
: []),
];
const onContextMenuPress = (
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
) => {
if (e.nativeEvent.name === ContextMenuActions.Bookmark) {
addBookmark(data);
showToast("Added to bookmarks", {
burntOptions: { preset: "done" },
});
} else if (e.nativeEvent.name === ContextMenuActions.RemoveBookmark) {
removeBookmark(data);
showToast("Removed from bookmarks", {
burntOptions: { preset: "done" },
});
} else if (e.nativeEvent.name === ContextMenuActions.Download) {
router.push({
pathname: "/videoPlayer",
params: { data: JSON.stringify(data), download: "true" },
});
} else if (
e.nativeEvent.name === ContextMenuActions.RemoveWatchHistoryItem
) {
removeFromWatchHistory(data);
showToast("Removed from Continue Watching", {
burntOptions: { preset: "done" },
});
}
};
return (
<TouchableOpacity
onPress={handlePress}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onLongPress={() => {}}
style={{ width: "100%" }}
>
<View width="100%">
<ContextMenu actions={contextMenuActions} onPress={onContextMenuPress}>
<View
marginBottom={4}
aspectRatio={9 / 14}
width="100%"
overflow="hidden"
borderRadius={24}
height="$14"
>
<Image source={{ uri: posterUrl }} width="100%" height="100%" />
</View>
</ContextMenu>
<Text fontWeight="bold" fontSize={14}>
{title}
</Text>
<View flexDirection="row" alignItems="center" gap={3}>
<Text fontSize={12} color="$ash100">
{type === "tv" ? "Show" : "Movie"}
</Text>
<View
height={6}
width={6}
borderRadius={24}
backgroundColor="$ash100"
/>
<Text fontSize={12} color="$ash100">
{isReleased() ? year : "Unreleased"}
</Text>
</View>
</View>
</TouchableOpacity>
);
}

View File

@@ -0,0 +1,48 @@
import { Linking } from "react-native";
import * as Haptics from "expo-haptics";
import { FontAwesome6, MaterialIcons } from "@expo/vector-icons";
import { Circle, View } from "tamagui";
import { DISCORD_LINK, GITHUB_LINK } from "~/constants/core";
import { BrandPill } from "../BrandPill";
export function Header() {
return (
<View alignItems="center" gap="$3" flexDirection="row">
<BrandPill />
<Circle
backgroundColor="$pillBackground"
size="$3.5"
pressStyle={{
opacity: 1,
scale: 1.05,
}}
onPress={async () => {
await Linking.openURL(DISCORD_LINK);
}}
onLongPress={() =>
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}
>
<MaterialIcons name="discord" size={28} color="white" />
</Circle>
<Circle
backgroundColor="$pillBackground"
size="$3.5"
pressStyle={{
opacity: 1,
scale: 1.05,
}}
onPress={async () => {
await Linking.openURL(GITHUB_LINK);
}}
onLongPress={() =>
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}
>
<FontAwesome6 name="github" size={28} color="white" />
</Circle>
</View>
);
}

View File

@@ -0,0 +1,48 @@
import type { ScrollViewProps } from "tamagui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollView } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient";
import { Header } from "./Header";
interface Props {
showHeader?: boolean;
}
export default function ScreenLayout({
children,
showHeader = true,
...props
}: ScrollViewProps & Props) {
const insets = useSafeAreaInsets();
return (
<LinearGradient
flex={1}
paddingVertical="$4"
paddingHorizontal="$4"
colors={[
"$shade900",
"$purple900",
"$purple800",
"$shade700",
"$shade900",
]}
locations={[0.02, 0.15, 0.2, 0.4, 0.8]}
start={[0, 0]}
end={[1, 1]}
flexGrow={1}
paddingTop={showHeader ? insets.top + 16 : insets.top + 50}
>
{showHeader && <Header />}
<ScrollView
marginTop="$4"
flexGrow={1}
showsVerticalScrollIndicator={false}
{...props}
>
{children}
</ScrollView>
</LinearGradient>
);
}

View File

@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { useAudioTrack } from "~/hooks/player/useAudioTrack";
import { useAudioTrackStore } from "~/stores/audio";
import { usePlayerStore } from "~/stores/player/store";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
export interface AudioTrack {
uri: string;
name: string;
language: string;
active?: boolean;
}
export const AudioTrackSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const tracks = usePlayerStore((state) => state.interface.audioTracks);
const setAudioTracks = usePlayerStore((state) => state.setAudioTracks);
const stream = usePlayerStore((state) => state.interface.currentStream);
const selectedTrack = useAudioTrackStore((state) => state.selectedTrack);
const setSelectedAudioTrack = useAudioTrackStore(
(state) => state.setSelectedAudioTrack,
);
const { synchronizePlayback } = useAudioTrack();
useEffect(() => {
if (tracks && selectedTrack) {
const needsUpdate = tracks.some(
(t) => t.active !== (t.uri === selectedTrack.uri),
);
if (needsUpdate) {
const updatedTracks = tracks.map((t) => ({
...t,
active: t.uri === selectedTrack.uri,
}));
setAudioTracks(updatedTracks);
}
}
}, [selectedTrack, setAudioTracks, tracks]);
if (!tracks?.length) return null;
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="volume-high"
size={24}
color={theme.silver300.val}
/>
}
onPress={() => setOpen(true)}
>
Audio
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Audio"
/>
<Settings.Content>
{tracks?.map((track) => (
<Settings.Item
key={track.language}
title={track.name}
iconRight={
track.active && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.playerSettingsUnactiveText.val}
/>
)
}
onPress={() => {
setSelectedAudioTrack(track);
if (stream) {
void synchronizePlayback(track, stream);
}
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,44 @@
import { Keyboard } from "react-native";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { usePlayer } from "~/hooks/player/usePlayer";
export const BackButton = () => {
const { dismissFullscreenPlayer } = usePlayer();
const router = useRouter();
return (
<Ionicons
name="arrow-back"
onPress={() => {
dismissFullscreenPlayer()
.then(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/");
}
return setTimeout(() => {
Keyboard.dismiss();
}, 100);
})
.catch(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/");
}
return setTimeout(() => {
Keyboard.dismiss();
}, 100);
});
}}
size={36}
color="white"
style={{
width: 100,
}}
/>
);
};

View File

@@ -0,0 +1,95 @@
import { useCallback, useMemo, useState } from "react";
import { Platform, TouchableOpacity } from "react-native";
import { isDevelopmentProvisioningProfile } from "modules/check-ios-certificate";
import { Text, View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { CaptionsSelector } from "./CaptionsSelector";
import { Controls } from "./Controls";
import { DownloadButton } from "./DownloadButton";
import { ProgressBar } from "./ProgressBar";
import { SeasonSelector } from "./SeasonEpisodeSelector";
import { SettingsSelector } from "./SettingsSelector";
import { SourceSelector } from "./SourceSelector";
import { mapMillisecondsToTime } from "./utils";
export const BottomControls = () => {
const status = usePlayerStore((state) => state.status);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
const [showRemaining, setShowRemaining] = useState(false);
const toggleTimeDisplay = useCallback(() => {
setIsIdle(false);
setShowRemaining(!showRemaining);
}, [showRemaining, setIsIdle]);
const { currentTime, remainingTime } = useMemo(() => {
if (status?.isLoaded) {
const current = mapMillisecondsToTime(status.positionMillis ?? 0);
const remaining = `-${mapMillisecondsToTime(
(status.durationMillis ?? 0) - (status.positionMillis ?? 0),
)}`;
return { currentTime: current, remainingTime: remaining };
} else {
return { currentTime: "", remainingTime: "" };
}
}, [status]);
const durationTime = useMemo(() => {
if (status?.isLoaded) {
return mapMillisecondsToTime(status.durationMillis ?? 0);
}
}, [status]);
if (status?.isLoaded) {
return (
<View
height={128}
width="100%"
flexDirection="column"
alignItems="center"
justifyContent="center"
padding={24}
>
<Controls>
<View flexDirection="row" justifyContent="space-between" width="$11">
<Text fontWeight="bold">{currentTime}</Text>
<Text marginHorizontal={1} fontWeight="bold">
/
</Text>
<TouchableOpacity onPress={toggleTimeDisplay}>
<Text fontWeight="bold">
{showRemaining ? remainingTime : durationTime}
</Text>
</TouchableOpacity>
</View>
<ProgressBar />
</Controls>
<View
flexDirection="row"
alignItems="center"
justifyContent="center"
gap={4}
paddingBottom={40}
>
{!isLocalFile && (
<>
<SeasonSelector />
<CaptionsSelector />
<SourceSelector />
<AudioTrackSelector />
<SettingsSelector />
{Platform.OS === "android" ||
(Platform.OS === "ios" && isDevelopmentProvisioningProfile()) ? (
<DownloadButton />
) : null}
</>
)}
</View>
</View>
);
}
};

View File

@@ -0,0 +1,95 @@
import { useMemo } from "react";
import Animated, {
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { Text, View } from "tamagui";
import { convertMilliSecondsToSeconds } from "~/lib/number";
import { useCaptionsStore } from "~/stores/captions";
import { usePlayerStore } from "~/stores/player/store";
export const captionIsVisible = (
start: number,
end: number,
delay: number,
currentTime: number,
) => {
const delayedStart = start / 1000 + delay;
const delayedEnd = end / 1000 + delay;
return (
Math.max(0, delayedStart) <= currentTime &&
Math.max(0, delayedEnd) >= currentTime
);
};
export const CaptionRenderer = () => {
const isIdle = usePlayerStore((state) => state.interface.isIdle);
const selectedCaption = useCaptionsStore((state) => state.selectedCaption);
const delay = useCaptionsStore((state) => state.delay);
const status = usePlayerStore((state) => state.status);
const translateY = useSharedValue(0);
const animatedStyles = useAnimatedStyle(() => {
return {
transform: [{ translateY: translateY.value }],
};
});
const transitionValue = useDerivedValue(() => {
return isIdle ? 50 : 0;
}, [isIdle]);
useAnimatedReaction(
() => {
return transitionValue.value;
},
(newValue) => {
translateY.value = withSpring(newValue);
},
);
const visibleCaptions = useMemo(
() =>
selectedCaption?.data.filter(({ start, end }) =>
captionIsVisible(
start,
end,
delay,
status?.isLoaded
? convertMilliSecondsToSeconds(status.positionMillis)
: 0,
),
),
[selectedCaption, delay, status],
);
if (!status?.isLoaded || !selectedCaption || !visibleCaptions?.length)
return null;
return (
<Animated.View
style={[
{
position: "absolute",
bottom: 95,
borderRadius: 4,
backgroundColor: "rgba(0, 0, 0, 0.6)",
paddingHorizontal: 16,
paddingVertical: 8,
},
animatedStyles,
]}
>
{visibleCaptions?.map((caption) => (
<View key={caption.index}>
<Text style={{ textAlign: "center" }}>{caption.text}</Text>
</View>
))}
</Animated.View>
);
};

View File

@@ -0,0 +1,174 @@
import type { LanguageCode } from "iso-639-1";
import type { ContentCaption } from "subsrt-ts/dist/types/handler";
import { useState } from "react";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { useMutation } from "@tanstack/react-query";
import { parse } from "subsrt-ts";
import { Spinner, useTheme, View } from "tamagui";
import type { Stream } from "@movie-web/provider-utils";
import type { CaptionWithData } from "~/stores/captions";
import { useToast } from "~/hooks/useToast";
import {
getCountryCodeFromLanguage,
getPrettyLanguageNameFromLocale,
} from "~/lib/language";
import { useCaptionsStore } from "~/stores/captions";
import { usePlayerStore } from "~/stores/player/store";
import { FlagIcon } from "../FlagIcon";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
const parseCaption = async (
caption: Stream["captions"][0],
): Promise<CaptionWithData> => {
const response = await fetch(caption.url);
const data = await response.text();
return {
...caption,
data: parse(data).filter(
(cue) => cue.type === "caption",
) as ContentCaption[],
};
};
export const CaptionsSelector = () => {
const { showToast } = useToast();
const theme = useTheme();
const [open, setOpen] = useState(false);
const captions = usePlayerStore(
(state) => state.interface.currentStream?.captions,
);
const selectedCaption = useCaptionsStore((state) => state.selectedCaption);
const setSelectedCaption = useCaptionsStore(
(state) => state.setSelectedCaption,
);
const downloadCaption = useMutation({
mutationKey: ["captions", selectedCaption?.id],
mutationFn: parseCaption,
onSuccess: (data) => {
setSelectedCaption(data);
},
});
if (!captions?.length) return null;
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="subtitles"
size={24}
color={theme.silver300.val}
/>
}
onPress={() => setOpen(true)}
>
Subtitles
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Subtitles"
rightButton={
<MWButton
color="$playerSettingsUnactiveText"
fontWeight="bold"
chromeless
onPress={() => {
showToast("Work in progress");
}}
>
Customize
</MWButton>
}
/>
<Settings.Content>
<Settings.Item
iconLeft={
<View
width="$5"
height="$3"
backgroundColor="$subtitleSelectorBackground"
borderRadius="$5"
/>
}
title={"Off"}
iconRight={
<>
{!selectedCaption?.id && (
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)}
</>
}
onPress={() => setSelectedCaption(null)}
/>
{captions?.map((caption) => (
<Settings.Item
iconLeft={
<View
width="$5"
height="$3"
backgroundColor="$subtitleSelectorBackground"
borderRadius="$5"
overflow="hidden"
>
<FlagIcon
languageCode={getCountryCodeFromLanguage(
caption.language as LanguageCode,
)}
/>
</View>
}
title={getPrettyLanguageNameFromLocale(caption.language) ?? ""}
iconRight={
<>
{selectedCaption?.id === caption.id && (
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)}
{downloadCaption.isPending &&
downloadCaption.variables.id === caption.id && (
<Spinner size="small" color="$loadingIndicator" />
)}
</>
}
onPress={() => downloadCaption.mutate(caption)}
key={caption.id}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,14 @@
import type { ViewProps } from "tamagui";
import React from "react";
import { View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
interface ControlsProps extends ViewProps {
children: React.ReactNode;
}
export const Controls = ({ children, ...props }: ControlsProps) => {
const idle = usePlayerStore((state) => state.interface.isIdle);
return <View {...props}>{!idle && children}</View>;
};

View File

@@ -0,0 +1,20 @@
import { View } from "tamagui";
import { BottomControls } from "./BottomControls";
import { Header } from "./Header";
import { MiddleControls } from "./MiddleControls";
export const ControlsOverlay = ({ isLoading }: { isLoading: boolean }) => {
return (
<View
width="100%"
flex={1}
flexDirection="column"
justifyContent="space-between"
>
<Header />
{!isLoading && <MiddleControls />}
<BottomControls />
</View>
);
};

View File

@@ -0,0 +1,58 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { findQuality } from "@movie-web/provider-utils";
import { useDownloadManager } from "~/hooks/useDownloadManager";
import { convertMetaToScrapeMedia } from "~/lib/meta";
import { usePlayerStore } from "~/stores/player/store";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
export const DownloadButton = () => {
const theme = useTheme();
const { startDownload } = useDownloadManager();
const stream = usePlayerStore((state) => state.interface.currentStream);
const meta = usePlayerStore((state) => state.meta);
if (!meta) return null;
const scrapeMedia = convertMetaToScrapeMedia(meta);
let url: string | undefined | null = null;
if (stream?.type === "file") {
const highestQuality = findQuality(stream);
url = highestQuality ? stream.qualities[highestQuality]?.url : null;
} else if (stream?.type === "hls") {
url = stream.playlist;
}
if (!url) return null;
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="download"
size={24}
color={theme.silver300.val}
/>
}
onPress={() =>
url &&
startDownload(
url,
stream?.type === "hls" ? "hls" : "mp4",
scrapeMedia,
).catch(console.error)
}
>
Download
</MWButton>
</Controls>
</>
);
};

View File

@@ -0,0 +1,45 @@
import { Text, View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
import { BrandPill } from "../BrandPill";
import { BackButton } from "./BackButton";
import { Controls } from "./Controls";
import { mapSeasonAndEpisodeNumberToText } from "./utils";
export const Header = () => {
const isIdle = usePlayerStore((state) => state.interface.isIdle);
const meta = usePlayerStore((state) => state.meta);
if (!isIdle) {
return (
<View
zIndex={50}
flexDirection="row"
alignItems="center"
justifyContent="space-between"
height={64}
paddingHorizontal="$8"
>
<View width={150}>
<Controls>
<BackButton />
</Controls>
</View>
{meta && (
<Text fontWeight="bold">
{meta.title} ({meta.releaseYear}){" "}
{meta.season !== undefined && meta.episode !== undefined
? mapSeasonAndEpisodeNumberToText(
meta.season.number,
meta.episode.number,
)
: ""}
</Text>
)}
<View alignItems="center" justifyContent="center" width={150}>
<BrandPill />
</View>
</View>
);
}
};

View File

@@ -0,0 +1,41 @@
import { TouchableWithoutFeedback } from "react-native";
import { View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
import { Controls } from "./Controls";
import { PlayButton } from "./PlayButton";
import { SeekButton } from "./SeekButton";
export const MiddleControls = () => {
const idle = usePlayerStore((state) => state.interface.isIdle);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const handleTouch = () => {
setIsIdle(!idle);
};
return (
<TouchableWithoutFeedback onPress={handleTouch}>
<View
position="absolute"
height="100%"
width="100%"
flex={1}
flexDirection="row"
alignItems="center"
justifyContent="center"
gap={82}
>
<Controls>
<SeekButton type="backward" />
</Controls>
<Controls>
<PlayButton />
</Controls>
<Controls>
<SeekButton type="forward" />
</Controls>
</View>
</TouchableWithoutFeedback>
);
};

View File

@@ -0,0 +1,33 @@
import { FontAwesome } from "@expo/vector-icons";
import { usePlayerStore } from "~/stores/player/store";
export const PlayButton = () => {
const videoRef = usePlayerStore((state) => state.videoRef);
const status = usePlayerStore((state) => state.status);
const playAudio = usePlayerStore((state) => state.playAudio);
const pauseAudio = usePlayerStore((state) => state.pauseAudio);
return (
<FontAwesome
name={status?.isLoaded && status.isPlaying ? "pause" : "play"}
size={36}
color="white"
onPress={() => {
if (status?.isLoaded) {
if (status.isPlaying) {
videoRef?.pauseAsync().catch(() => {
console.log("Error pausing video");
});
void pauseAudio();
} else {
videoRef?.playAsync().catch(() => {
console.log("Error playing video");
});
void playAudio();
}
}
}}
/>
);
};

View File

@@ -0,0 +1,59 @@
import type { SheetProps } from "tamagui";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed";
import { Settings } from "./settings/Sheet";
export const PlaybackSpeedSelector = (props: SheetProps) => {
const theme = useTheme();
const { speeds, currentSpeed, changePlaybackSpeed } = usePlaybackSpeed();
return (
<Settings.Sheet
forceRemoveScrollEnabled={props.open}
open={props.open}
onOpenChange={props.onOpenChange}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => props.onOpenChange?.(false)}
/>
}
title="Playback settings"
/>
<Settings.Content>
{speeds.map((speed) => (
<Settings.Item
key={speed}
title={`${speed}x`}
iconRight={
speed === currentSpeed && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)
}
onPress={() => {
changePlaybackSpeed(speed)
.then(() => props.onOpenChange?.(false))
.catch((err) => {
console.log("error", err);
});
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
);
};

View File

@@ -0,0 +1,37 @@
import { useCallback } from "react";
import { TouchableOpacity } from "react-native";
import { usePlayerStore } from "~/stores/player/store";
import VideoSlider from "./VideoSlider";
export const ProgressBar = () => {
const status = usePlayerStore((state) => state.status);
const videoRef = usePlayerStore((state) => state.videoRef);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const updateProgress = useCallback(
(newProgress: number) => {
videoRef?.setStatusAsync({ positionMillis: newProgress }).catch(() => {
console.error("Error updating progress");
});
},
[videoRef],
);
if (status?.isLoaded) {
return (
<TouchableOpacity
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingBottom: 36,
paddingTop: 24,
}}
onPress={() => setIsIdle(false)}
>
<VideoSlider onSlidingComplete={updateProgress} />
</TouchableOpacity>
);
}
};

View File

@@ -0,0 +1,93 @@
import type { SheetProps } from "tamagui";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { constructFullUrl } from "@movie-web/provider-utils";
import { usePlayerStore } from "~/stores/player/store";
import { Settings } from "./settings/Sheet";
export const QualitySelector = (props: SheetProps) => {
const theme = useTheme();
const videoRef = usePlayerStore((state) => state.videoRef);
const videoSrc = usePlayerStore((state) => state.videoSrc);
const stream = usePlayerStore((state) => state.interface.currentStream);
const hlsTracks = usePlayerStore((state) => state.interface.hlsTracks);
if (!videoRef || !videoSrc || !stream) return null;
let qualityMap: { quality: string; url: string }[];
let currentQuality: string | undefined;
if (stream.type === "file") {
const { qualities } = stream;
currentQuality = Object.keys(qualities).find(
(key) => qualities[key as keyof typeof qualities]!.url === videoSrc.uri,
);
qualityMap = Object.keys(qualities).map((key) => ({
quality: key,
url: qualities[key as keyof typeof qualities]!.url,
}));
} else if (stream.type === "hls") {
if (!hlsTracks?.video) return null;
qualityMap = hlsTracks.video.map((video) => ({
quality:
(video.properties[0]?.attributes.resolution as string) ?? "unknown",
url: constructFullUrl(stream.playlist, video.uri),
}));
} else {
return null;
}
return (
<>
<Settings.Sheet
forceRemoveScrollEnabled={props.open}
open={props.open}
onOpenChange={props.onOpenChange}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => props.onOpenChange?.(false)}
/>
}
title="Quality settings"
/>
<Settings.Content>
{qualityMap?.map((quality) => (
<Settings.Item
key={quality.quality}
title={quality.quality}
iconRight={
quality.quality === currentQuality && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)
}
onPress={() => {
void videoRef.unloadAsync();
void videoRef.loadAsync(
{ uri: quality.url, headers: stream.headers },
{ shouldPlay: true },
);
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,115 @@
import type { ReactNode } from "react";
import React from "react";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { Text, useTheme, View } from "tamagui";
export interface ScrapeItemProps {
status: "failure" | "pending" | "notfound" | "success" | "waiting";
name: string;
id?: string;
percentage?: number;
children?: ReactNode;
}
export interface ScrapeCardProps extends ScrapeItemProps {
hasChildren?: boolean;
}
const statusTextMap: Partial<Record<ScrapeCardProps["status"], string>> = {
notfound: "Doesn't have the video",
failure: "Failed to scrape",
pending: "Checking for videos...",
};
const mapPercentageToIcon = (percentage: number) => {
const slice = Math.floor(percentage / 12.5);
return `circle-slice-${slice === 0 ? 1 : slice}`;
};
export function StatusCircle({
type,
percentage,
}: {
type: ScrapeItemProps["status"];
percentage: number;
}) {
const theme = useTheme();
return (
<>
{type === "waiting" && (
<MaterialCommunityIcons
name="circle-outline"
size={40}
color={theme.scrapingNoResult.val}
/>
)}
{type === "pending" && (
<MaterialCommunityIcons
name={mapPercentageToIcon(percentage) as "circle-slice-1"}
size={40}
color={theme.scrapingLoading.val}
/>
)}
{type === "failure" && (
<MaterialCommunityIcons
name="close-circle"
size={40}
color={theme.scrapingError.val}
/>
)}
{type === "notfound" && (
<MaterialIcons
name="remove-circle"
size={40}
color={theme.scrapingNoResult.val}
/>
)}
{type === "success" && (
<MaterialIcons
name="check-circle"
size={40}
color={theme.scrapingSuccess.val}
/>
)}
</>
);
}
export function ScrapeItem(props: ScrapeItemProps) {
const text = statusTextMap[props.status];
return (
<View flex={1} flexDirection="column">
<View flexDirection="row" alignItems="center" gap={16}>
<StatusCircle type={props.status} percentage={props.percentage ?? 0} />
<Text
fontSize={18}
color={props.status === "pending" ? "$scrapingLoading" : "white"}
>
{props.name}
</Text>
</View>
<View flexDirection="row" alignItems="center" gap={16}>
<View width={40} />
<View>{text && <Text fontSize={18}>{text}</Text>}</View>
</View>
<View marginLeft={48}>{props.children}</View>
</View>
);
}
export function ScrapeCard(props: ScrapeCardProps) {
return (
<View width={384}>
<View
width="100%"
borderRadius={10}
paddingVertical={12}
paddingHorizontal={24}
backgroundColor={props.hasChildren ? "$scrapingCard" : "transparent"}
>
<ScrapeItem {...props} />
</View>
</View>
);
}

View File

@@ -0,0 +1,214 @@
import { useEffect, useRef } from "react";
import { SafeAreaView } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import { useRouter } from "expo-router";
import { View } from "tamagui";
import type { RunOutput, ScrapeMedia } from "@movie-web/provider-utils";
import {
extractTracksFromHLS,
filterAudioTracks,
findQuality,
} from "@movie-web/provider-utils";
import type { ItemData } from "../item/item";
import type { PlayerMeta } from "~/stores/player/slices/video";
import { useMeta } from "~/hooks/player/useMeta";
import { useScrape } from "~/hooks/player/useSourceScrape";
import { useDownloadManager } from "~/hooks/useDownloadManager";
import { convertMetaToScrapeMedia } from "~/lib/meta";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store";
import { BackButton } from "./BackButton";
import { ScrapeCard, ScrapeItem } from "./ScrapeCard";
interface ScraperProcessProps {
data?: Partial<ItemData>;
media?: ScrapeMedia;
download?: boolean;
}
export const ScraperProcess = ({
data,
media,
download,
}: ScraperProcessProps) => {
const router = useRouter();
const { startDownload } = useDownloadManager();
const scrollViewRef = useRef<ScrollView>(null);
const { convertIdToMeta } = useMeta();
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
const setStream = usePlayerStore((state) => state.setCurrentStream);
const setHlsTracks = usePlayerStore((state) => state.setHlsTracks);
const setAudioTracks = usePlayerStore((state) => state.setAudioTracks);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const setSourceId = usePlayerStore((state) => state.setSourceId);
useEffect(() => {
const fetchData = async () => {
if (!data?.id && !media) return router.back();
let streamResult: RunOutput | null = null;
let meta: PlayerMeta | undefined = undefined;
if (!media && data?.id && data.type) {
meta = await convertIdToMeta(
data.id,
data.type,
data.season,
data.episode,
);
if (!meta) return router.back();
}
const scrapeMedia = media ?? (meta && convertMetaToScrapeMedia(meta));
if (!scrapeMedia) return router.back();
streamResult = await startScraping(scrapeMedia);
if (!streamResult) return router.back();
if (download) {
if (streamResult.stream.type === "file") {
const quality = findQuality(streamResult.stream);
const url = quality
? streamResult.stream.qualities[quality]?.url
: null;
if (!url) return;
startDownload(url, "mp4", scrapeMedia).catch(console.error);
} else if (streamResult.stream.type === "hls") {
startDownload(streamResult.stream.playlist, "hls", scrapeMedia).catch(
console.error,
);
}
return router.back();
}
setStream(streamResult.stream);
if (streamResult.stream.type === "hls") {
const tracks = await extractTracksFromHLS(
streamResult.stream.playlist,
{
...streamResult.stream.preferredHeaders,
...streamResult.stream.headers,
},
);
if (tracks) setHlsTracks(tracks);
if (tracks?.audio.length) {
setAudioTracks(
filterAudioTracks(tracks, streamResult.stream.playlist),
);
}
}
setPlayerStatus(PlayerStatus.READY);
setSourceId(streamResult.sourceId);
};
void fetchData();
}, [
convertIdToMeta,
data,
download,
media,
router,
setAudioTracks,
setHlsTracks,
setPlayerStatus,
setSourceId,
setStream,
startDownload,
startScraping,
]);
let currentProviderIndex = sourceOrder.findIndex(
(s) => s.id === currentSource || s.children.includes(currentSource ?? ""),
);
if (currentProviderIndex === -1) {
currentProviderIndex = sourceOrder.length - 1;
}
useEffect(() => {
scrollViewRef.current?.scrollTo({
y: currentProviderIndex * 110,
animated: true,
});
}, [currentProviderIndex]);
return (
<SafeAreaView
style={{
display: "flex",
height: "100%",
flexDirection: "column",
flex: 1,
}}
>
<View
flex={1}
alignItems="center"
justifyContent="center"
backgroundColor="$screenBackground"
>
<View position="absolute" top={40} left={40}>
<BackButton />
</View>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
paddingVertical: 64,
}}
>
{sourceOrder.map((order) => {
const source = sources[order.id];
if (!source) return null;
const distance = Math.abs(
sourceOrder.findIndex((o) => o.id === order.id) -
currentProviderIndex,
);
return (
<View
key={order.id}
style={{ opacity: Math.max(0, 1 - distance * 0.3) }}
>
<ScrapeCard
id={order.id}
name={source.name}
status={source.status}
hasChildren={order.children.length > 0}
percentage={source.percentage}
>
<View
marginTop={order.children.length > 0 ? 8 : 0}
flexDirection="column"
gap={16}
>
{order.children.map((embedId) => {
const embed = sources[embedId];
if (!embed) return null;
return (
<ScrapeItem
id={embedId}
name={embed.name}
status={embed.status}
percentage={embed.percentage}
key={embedId}
/>
);
})}
</View>
</ScrapeCard>
</View>
);
})}
</ScrollView>
</View>
</SafeAreaView>
);
};

View File

@@ -0,0 +1,184 @@
import type { SheetProps } from "tamagui";
import { useState } from "react";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useTheme, View } from "tamagui";
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
import { usePlayerStore } from "~/stores/player/store";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
const EpisodeSelector = ({
seasonNumber,
setSelectedSeason,
...props
}: SheetProps & {
seasonNumber: number;
setSelectedSeason: (season: number | null) => void;
}) => {
const theme = useTheme();
const meta = usePlayerStore((state) => state.meta);
const setMeta = usePlayerStore((state) => state.setMeta);
const { data, isLoading } = useQuery({
queryKey: ["seasonEpisodes", meta!.tmdbId, seasonNumber],
queryFn: async () => {
return fetchSeasonDetails(meta!.tmdbId, seasonNumber);
},
enabled: meta !== null,
});
if (!meta) return null;
return (
<Settings.Sheet
open={props.open}
onOpenChange={props.onOpenChange}
{...props}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame isLoading={isLoading}>
<Settings.Header
icon={
<Ionicons
name="arrow-back"
size={24}
color={theme.silver300.val}
onPress={() => {
setSelectedSeason(null);
props.onOpenChange?.(false);
}}
/>
}
title={`Season ${data?.season_number}`}
/>
<Settings.Content>
{data?.episodes.map((episode) => (
<Settings.Item
key={episode.id}
iconLeft={
<View
width={32}
height={32}
backgroundColor="#121c24"
justifyContent="center"
alignItems="center"
borderRadius={6}
>
<Settings.Text fontSize={14}>
E{episode.episode_number}
</Settings.Text>
</View>
}
title={episode.name}
onPress={() => {
setMeta({
...meta,
episode: {
number: episode.episode_number,
tmdbId: episode.id.toString(),
},
});
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
);
};
export const SeasonSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const [episodeOpen, setEpisodeOpen] = useState(false);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const meta = usePlayerStore((state) => state.meta);
const { data, isLoading } = useQuery({
queryKey: ["seasons", meta!.tmdbId],
queryFn: async () => {
return fetchMediaDetails(meta!.tmdbId, "tv");
},
enabled: meta !== null,
});
if (meta?.type !== "show") return null;
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="audio-video"
size={24}
color={theme.silver300.val}
/>
}
onPress={() => setOpen(true)}
>
Episodes
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame isLoading={isLoading}>
{episodeOpen && selectedSeason ? (
<EpisodeSelector
seasonNumber={selectedSeason}
setSelectedSeason={setSelectedSeason}
open={episodeOpen}
onOpenChange={setEpisodeOpen}
/>
) : (
<>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title={data?.result.name ?? ""}
/>
<Settings.Content>
{data?.result.seasons.map((season) => (
<Settings.Item
key={season.season_number}
title={`Season ${season.season_number}`}
iconRight={
<MaterialCommunityIcons
name="chevron-right"
size={24}
color="white"
/>
}
onPress={() => {
setSelectedSeason(season.season_number);
setEpisodeOpen(true);
}}
/>
))}
</Settings.Content>
</>
)}
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,36 @@
import { MaterialIcons } from "@expo/vector-icons";
import { usePlayerStore } from "~/stores/player/store";
interface SeekProps {
type: "forward" | "backward";
}
export const SeekButton = ({ type }: SeekProps) => {
const videoRef = usePlayerStore((state) => state.videoRef);
const status = usePlayerStore((state) => state.status);
const setAudioPositionAsync = usePlayerStore(
(state) => state.setAudioPositionAsync,
);
return (
<MaterialIcons
name={type === "forward" ? "forward-10" : "replay-10"}
size={36}
color="white"
onPress={() => {
if (status?.isLoaded) {
const position =
type === "forward"
? status.positionMillis + 10000
: status.positionMillis - 10000;
videoRef?.setPositionAsync(position).catch(() => {
console.log("Error seeking backwards");
});
void setAudioPositionAsync(position);
}
}}
/>
);
};

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { PlaybackSpeedSelector } from "./PlaybackSpeedSelector";
import { QualitySelector } from "./QualitySelector";
import { Settings } from "./settings/Sheet";
export const SettingsSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const [qualityOpen, setQualityOpen] = useState(false);
const [playbackOpen, setPlaybackOpen] = useState(false);
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialIcons
name="display-settings"
size={24}
color={theme.silver300.val}
/>
}
onPress={() => setOpen(true)}
>
Settings
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<QualitySelector open={qualityOpen} onOpenChange={setQualityOpen} />
<PlaybackSpeedSelector
open={playbackOpen}
onOpenChange={setPlaybackOpen}
/>
<Settings.Header
icon={
<MaterialIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Settings"
/>
<Settings.Content>
<Settings.Item
title="Quality"
iconLeft={
<MaterialIcons
name="hd"
size={24}
color={theme.playerSettingsUnactiveText.val}
/>
}
iconRight={
<MaterialCommunityIcons
name="chevron-right"
size={24}
color="white"
/>
}
onPress={() => setQualityOpen(true)}
/>
<Settings.Item
title="Playback speed"
iconLeft={
<MaterialIcons
name="speed"
size={24}
color={theme.playerSettingsUnactiveText.val}
/>
}
iconRight={
<MaterialCommunityIcons
name="chevron-right"
size={24}
color="white"
/>
}
onPress={() => setPlaybackOpen(true)}
/>
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,222 @@
import type { SheetProps } from "tamagui";
import { useCallback, useEffect, useState } from "react";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Spinner, Text, useTheme, View } from "tamagui";
import { getBuiltinSources, providers } from "@movie-web/provider-utils";
import {
useEmbedScrape,
useSourceScrape,
} from "~/hooks/player/useSourceScrape";
import { usePlayerStore } from "~/stores/player/store";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
const SourceItem = ({
name,
id,
active,
embed,
onPress,
}: {
name: string;
id: string;
active?: boolean;
embed?: { url: string; embedId: string };
onPress?: (id: string) => void;
}) => {
const theme = useTheme();
const { mutate, isPending, isError } = useEmbedScrape();
return (
<Settings.Item
title={name}
iconRight={
<>
{active && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)}
{isError && (
<MaterialCommunityIcons
name="alert-circle"
size={24}
color={theme.scrapingError.val}
/>
)}
{isPending && <Spinner size="small" color="$scrapingLoading" />}
</>
}
onPress={() => {
if (onPress) {
onPress(id);
return;
}
if (embed) {
mutate({
url: embed.url,
embedId: embed.embedId,
sourceId: id,
});
}
}}
/>
);
};
const EmbedsPart = ({
sourceId,
closeParent,
...props
}: SheetProps & {
sourceId: string;
closeParent?: (open: boolean) => void;
}) => {
const theme = useTheme();
const { data, isPending, isError, error, status } = useSourceScrape(sourceId);
useEffect(() => {
if (status === "success" && !isError && data && data?.length <= 1) {
props.onOpenChange?.(false);
closeParent?.(false);
}
}, [status, data, isError, props, closeParent]);
return (
<Settings.Sheet
open={props.open}
onOpenChange={props.onOpenChange}
{...props}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<Ionicons
name="arrow-back"
size={24}
color={theme.silver300.val}
onPress={() => {
props.onOpenChange?.(false);
}}
/>
}
title={providers.getMetadata(sourceId)?.name ?? "Embeds"}
/>
<Settings.Content>
<View alignItems="center" justifyContent="center">
{isPending && <Spinner size="small" color="$loadingIndicator" />}
{error && <Text>Something went wrong!</Text>}
</View>
{data && data?.length > 1 && (
<Settings.Content>
{data.map((embed) => {
const metaData = providers.getMetadata(embed.embedId)!;
return (
<SourceItem
key={embed.embedId}
name={metaData.name}
id={embed.embedId}
embed={embed}
/>
);
})}
</Settings.Content>
)}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
);
};
export const SourceSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const [embedOpen, setEmbedOpen] = useState(false);
const sourceId = usePlayerStore((state) => state.interface.sourceId);
const setSourceId = usePlayerStore((state) => state.setSourceId);
const isActive = useCallback(
(id: string) => {
return sourceId === id;
},
[sourceId],
);
return (
<>
<Controls>
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="video"
size={24}
color={theme.silver300.val}
/>
}
onPress={() => setOpen(true)}
>
Source
</MWButton>
</Controls>
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
{embedOpen && sourceId ? (
<EmbedsPart
sourceId={sourceId}
open={embedOpen}
onOpenChange={setEmbedOpen}
closeParent={setOpen}
/>
) : (
<>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Sources"
/>
<Settings.Content>
{getBuiltinSources()
.sort((a, b) => b.rank - a.rank)
.map((source) => (
<SourceItem
key={source.id}
name={source.name}
id={source.id}
active={isActive(source.id)}
onPress={(id) => {
setSourceId(id);
setEmbedOpen(true);
}}
/>
))}
</Settings.Content>
</>
)}
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -0,0 +1,72 @@
import React from "react";
import Animated, {
Easing,
useAnimatedProps,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Circle, Svg } from "react-native-svg";
import { AntDesign } from "@expo/vector-icons";
import { View } from "tamagui";
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export const StatusCircle = ({
type,
percentage = 0,
}: {
type: string;
percentage: number;
}) => {
const radius = 25;
const strokeWidth = 5;
const circleCircumference = 2 * Math.PI * radius;
const strokeDashoffset = useSharedValue(circleCircumference);
React.useEffect(() => {
strokeDashoffset.value = withTiming(
circleCircumference - (circleCircumference * percentage) / 100,
{
duration: 500,
easing: Easing.linear,
},
);
}, [circleCircumference, percentage, strokeDashoffset]);
const animatedProps = useAnimatedProps(() => ({
strokeDashoffset: strokeDashoffset.value,
}));
const renderIcon = () => {
switch (type) {
case "success":
return <AntDesign name="checkcircle" size={50} color="green" />;
case "error":
return <AntDesign name="closecircle" size={50} color="red" />;
default:
return null;
}
};
return (
<View justifyContent="center" alignItems="center" position="relative">
<Svg height="60" width="60" viewBox="0 0 60 60">
{type === "loading" && (
<AnimatedCircle
cx="30"
cy="30"
r={radius}
stroke="blue"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circleCircumference}
animatedProps={animatedProps}
strokeLinecap="round"
/>
)}
</Svg>
{renderIcon()}
</View>
);
};

View File

@@ -0,0 +1,397 @@
import type { AVPlaybackStatus } from "expo-av";
import type { SharedValue } from "react-native-reanimated";
import { useEffect, useState } from "react";
import { Dimensions, Platform } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ResizeMode, Video } from "expo-av";
import * as Haptics from "expo-haptics";
import * as NavigationBar from "expo-navigation-bar";
import * as Network from "expo-network";
import { useRouter } from "expo-router";
import * as StatusBar from "expo-status-bar";
import { Feather } from "@expo/vector-icons";
import { Spinner, useTheme, View } from "tamagui";
import { findHLSQuality, findQuality } from "@movie-web/provider-utils";
import { useAudioTrack } from "~/hooks/player/useAudioTrack";
import { useBrightness } from "~/hooks/player/useBrightness";
import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed";
import { usePlayer } from "~/hooks/player/usePlayer";
import { useVolume } from "~/hooks/player/useVolume";
import {
convertMetaToItemData,
convertMetaToScrapeMedia,
getNextEpisode,
} from "~/lib/meta";
import { useAudioTrackStore } from "~/stores/audio";
import { usePlayerStore } from "~/stores/player/store";
import {
DefaultQuality,
useNetworkSettingsStore,
usePlayerSettingsStore,
useWatchHistoryStore,
} from "~/stores/settings";
import { CaptionRenderer } from "./CaptionRenderer";
import { ControlsOverlay } from "./ControlsOverlay";
export const VideoPlayer = () => {
const {
brightness,
showBrightnessOverlay,
setShowBrightnessOverlay,
handleBrightnessChange,
} = useBrightness();
const { volume, showVolumeOverlay, setShowVolumeOverlay } = useVolume();
const { currentSpeed } = usePlaybackSpeed();
const { synchronizePlayback } = useAudioTrack();
const { dismissFullscreenPlayer } = usePlayer();
const [isLoading, setIsLoading] = useState(true);
const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN);
const [hasStartedPlaying, setHasStartedPlaying] = useState(false);
const router = useRouter();
const scale = useSharedValue(1);
const state = usePlayerStore((state) => state.interface.state);
const isIdle = usePlayerStore((state) => state.interface.isIdle);
const stream = usePlayerStore((state) => state.interface.currentStream);
const selectedAudioTrack = useAudioTrackStore((state) => state.selectedTrack);
const videoRef = usePlayerStore((state) => state.videoRef);
const setVideoRef = usePlayerStore((state) => state.setVideoRef);
const videoSrc = usePlayerStore((state) => state.videoSrc) ?? undefined;
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
const setStatus = usePlayerStore((state) => state.setStatus);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const toggleAudio = usePlayerStore((state) => state.toggleAudio);
const toggleState = usePlayerStore((state) => state.toggleState);
const meta = usePlayerStore((state) => state.meta);
const setMeta = usePlayerStore((state) => state.setMeta);
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
const { gestureControls, autoPlay } = usePlayerSettingsStore();
const { updateWatchHistory, removeFromWatchHistory, getWatchHistoryItem } =
useWatchHistoryStore();
const { wifiDefaultQuality, mobileDataDefaultQuality } =
useNetworkSettingsStore();
const updateResizeMode = (newMode: ResizeMode) => {
setResizeMode(newMode);
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const pinchGesture = Gesture.Pinch().onUpdate((e) => {
scale.value = e.scale;
if (scale.value > 1 && resizeMode !== ResizeMode.COVER) {
runOnJS(updateResizeMode)(ResizeMode.COVER);
} else if (scale.value <= 1 && resizeMode !== ResizeMode.CONTAIN) {
runOnJS(updateResizeMode)(ResizeMode.CONTAIN);
}
});
const doubleTapGesture = Gesture.Tap()
.enabled(gestureControls && isIdle)
.numberOfTaps(2)
.onEnd(() => {
runOnJS(toggleAudio)();
runOnJS(toggleState)();
});
const screenHalfWidth = Dimensions.get("window").width / 2;
const panGesture = Gesture.Pan()
.enabled(gestureControls && isIdle)
.onStart((event) => {
if (event.x > screenHalfWidth) {
runOnJS(setShowVolumeOverlay)(true);
} else {
runOnJS(setShowBrightnessOverlay)(true);
}
})
.onUpdate((event) => {
const divisor = 5000;
const directionMultiplier = event.velocityY < 0 ? 1 : -1;
const change = directionMultiplier * Math.abs(event.velocityY / divisor);
if (event.x > screenHalfWidth) {
const newVolume = Math.max(0, Math.min(1, volume.value + change));
volume.value = newVolume;
} else {
const newBrightness = Math.max(
0,
Math.min(1, brightness.value + change),
);
brightness.value = newBrightness;
runOnJS(handleBrightnessChange)(newBrightness);
}
})
.onEnd((event) => {
if (event.x > screenHalfWidth) {
runOnJS(setShowVolumeOverlay)(false);
} else {
runOnJS(setShowBrightnessOverlay)(false);
}
});
const composedGesture = Gesture.Race(
panGesture,
pinchGesture,
doubleTapGesture,
);
StatusBar.setStatusBarHidden(true);
if (Platform.OS === "android") {
void NavigationBar.setVisibilityAsync("hidden");
}
useEffect(() => {
const initializePlayer = async () => {
if (videoSrc?.uri && isLocalFile) return;
if (!stream) {
await dismissFullscreenPlayer();
return router.back();
}
setIsLoading(true);
const { type: networkType } = await Network.getNetworkStateAsync();
const defaultQuality =
networkType === Network.NetworkStateType.WIFI
? wifiDefaultQuality
: mobileDataDefaultQuality;
const highest = defaultQuality === DefaultQuality.Highest;
let url = null;
if (stream.type === "hls") {
url = await findHLSQuality(stream.playlist, stream.headers, highest);
}
if (stream.type === "file") {
const chosenQuality = findQuality(stream, highest);
url = chosenQuality ? stream.qualities[chosenQuality]?.url : null;
}
if (!url) {
await dismissFullscreenPlayer();
return router.back();
}
setVideoSrc({
uri: url,
headers: {
...stream.preferredHeaders,
...stream.headers,
},
});
setIsLoading(false);
};
void initializePlayer();
const timeout = setTimeout(() => {
if (!hasStartedPlaying) {
router.back();
}
}, 60000);
return () => {
if (meta) {
const item = convertMetaToItemData(meta);
const scrapeMedia = convertMetaToScrapeMedia(meta);
updateWatchHistory(
item,
scrapeMedia,
videoRef?.props.positionMillis ?? 0,
);
}
clearTimeout(timeout);
void synchronizePlayback();
};
}, [
isLocalFile,
dismissFullscreenPlayer,
hasStartedPlaying,
meta,
router,
selectedAudioTrack,
setVideoSrc,
stream,
synchronizePlayback,
updateWatchHistory,
videoRef?.props.positionMillis,
videoSrc?.uri,
wifiDefaultQuality,
mobileDataDefaultQuality,
]);
const onVideoLoadStart = () => {
setIsLoading(true);
};
const onReadyForDisplay = () => {
setIsLoading(false);
setHasStartedPlaying(true);
if (videoRef) {
void videoRef.setRateAsync(currentSpeed, true);
if (meta) {
const media = convertMetaToScrapeMedia(meta);
const watchHistoryItem = getWatchHistoryItem(media);
if (watchHistoryItem) {
void videoRef.setPositionAsync(watchHistoryItem.positionMillis);
}
}
}
};
const onPlaybackStatusUpdate = async (status: AVPlaybackStatus) => {
setStatus(status);
if (meta && status.isLoaded && status.didJustFinish) {
const item = convertMetaToItemData(meta);
removeFromWatchHistory(item);
}
if (
status.isLoaded &&
status.didJustFinish &&
!status.isLooping &&
autoPlay
) {
if (meta?.type !== "show") return;
const nextEpisodeMeta = await getNextEpisode(meta);
if (!nextEpisodeMeta) return;
setMeta(nextEpisodeMeta);
const media = convertMetaToScrapeMedia(nextEpisodeMeta);
router.replace({
pathname: "/videoPlayer",
params: { media: JSON.stringify(media) },
});
}
};
return (
<GestureDetector gesture={composedGesture}>
<View
flex={1}
flexDirection="row"
alignItems="center"
justifyContent="center"
backgroundColor="black"
>
<Video
ref={setVideoRef}
source={videoSrc}
shouldPlay={state === "playing"}
resizeMode={resizeMode}
volume={volume.value}
rate={currentSpeed}
onLoadStart={onVideoLoadStart}
onReadyForDisplay={onReadyForDisplay}
onPlaybackStatusUpdate={onPlaybackStatusUpdate}
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
...(!isIdle && {
opacity: 0.7,
}),
},
]}
onTouchStart={() => setIsIdle(!isIdle)}
/>
<View
height="100%"
width="100%"
alignItems="center"
justifyContent="center"
>
{isLoading && (
<Spinner
size="large"
color="$loadingIndicator"
position="absolute"
/>
)}
<ControlsOverlay isLoading={isLoading} />
</View>
{showVolumeOverlay && <GestureOverlay value={volume} type="volume" />}
{showBrightnessOverlay && (
<GestureOverlay value={brightness} type="brightness" />
)}
<CaptionRenderer />
</View>
</GestureDetector>
);
};
function GestureOverlay(props: {
value: SharedValue<number>;
type: "brightness" | "volume";
}) {
const theme = useTheme();
const insets = useSafeAreaInsets();
const animatedStyle = useAnimatedStyle(() => {
return {
height: `${props.value.value * 100}%`,
borderTopLeftRadius: props.value.value >= 0.98 ? 44 : 0,
borderTopRightRadius: props.value.value >= 0.98 ? 44 : 0,
};
});
return (
<View
position="absolute"
left={props.type === "brightness" ? insets.left + 20 : undefined}
right={props.type === "volume" ? insets.right + 20 : undefined}
borderRadius="$4"
gap={8}
height="50%"
>
<Feather
size={24}
color="white"
style={{
bottom: 20,
}}
name={props.type === "brightness" ? "sun" : "volume-2"}
/>
<View
width={14}
backgroundColor={theme.progressBackground}
justifyContent="flex-end"
borderRadius="$4"
left={4}
bottom={20}
height="100%"
>
<Animated.View
style={[
animatedStyle,
{
width: "100%",
backgroundColor: theme.progressFilled.val,
borderBottomRightRadius: 44,
borderBottomLeftRadius: 44,
},
]}
/>
</View>
</View>
);
}

View File

@@ -0,0 +1,176 @@
import type {
HandlerStateChangeEvent,
PanGestureHandlerGestureEvent,
TapGestureHandlerEventPayload,
} from "react-native-gesture-handler";
import React, { useEffect, useRef } from "react";
import { Dimensions } from "react-native";
import {
PanGestureHandler,
State,
TapGestureHandler,
} from "react-native-gesture-handler";
import Animated, {
runOnJS,
useAnimatedGestureHandler,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { useTheme, View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
const clamp = (value: number, lowerBound: number, upperBound: number) => {
"worklet";
return Math.min(Math.max(lowerBound, value), upperBound);
};
interface VideoSliderProps {
onSlidingComplete?: (value: number) => void;
}
const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
const theme = useTheme();
const tapRef = useRef<TapGestureHandler>(null);
const panRef = useRef<PanGestureHandler>(null);
const status = usePlayerStore((state) => state.status);
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
const width = Dimensions.get("screen").width - 120;
const knobSize_ = 20;
const trackSize_ = 8;
const minimumValue = 0;
const maximumValue = status?.isLoaded ? status.durationMillis! : 0;
const value = status?.isLoaded ? status.positionMillis : 0;
const valueToX = (v: number) => {
if (maximumValue === minimumValue) return 0;
return (width * (v - minimumValue)) / (maximumValue - minimumValue);
};
const xToValue = (x: number) => {
"worklet";
if (maximumValue === minimumValue) return minimumValue;
return (x / width) * (maximumValue - minimumValue) + minimumValue;
};
const valueX = valueToX(value);
const translateX = useSharedValue(valueToX(value));
const isDragging = useSharedValue(false);
useEffect(() => {
if (!isDragging.value) {
translateX.value = clamp(valueX, 0, width - knobSize_);
}
}, [valueX, isDragging.value, translateX, width]);
const _onSlidingComplete = (xValue: number) => {
"worklet";
if (onSlidingComplete) runOnJS(onSlidingComplete)(xToValue(xValue));
};
const _onActive = (value: number) => {
"worklet";
isDragging.value = true;
translateX.value = clamp(value, 0, width - knobSize_);
runOnJS(setIsIdle)(false);
};
const _onEnd = () => {
"worklet";
isDragging.value = false;
_onSlidingComplete(translateX.value);
};
const onGestureEvent = useAnimatedGestureHandler<
PanGestureHandlerGestureEvent,
{ offsetX: number }
>({
onStart: (_, ctx) => (ctx.offsetX = translateX.value),
onActive: (event, ctx) => _onActive(event.translationX + ctx.offsetX),
onEnd: _onEnd,
onCancel: _onEnd,
onFinish: _onEnd,
});
const onTapEvent = (
event: HandlerStateChangeEvent<TapGestureHandlerEventPayload>,
) => {
if (event.nativeEvent.state === State.ACTIVE) {
_onActive(event.nativeEvent.x);
_onSlidingComplete(event.nativeEvent.x);
}
};
const scrollTranslationStyle = useAnimatedStyle(() => {
return { transform: [{ translateX: translateX.value }] };
});
const progressStyle = useAnimatedStyle(() => {
return {
width: translateX.value + knobSize_,
};
});
return (
<TapGestureHandler
ref={tapRef}
onHandlerStateChange={onTapEvent}
simultaneousHandlers={panRef}
>
<View
style={[
{
alignItems: "center",
justifyContent: "center",
height: knobSize_,
width,
},
]}
>
<View
style={[
{
height: trackSize_,
borderRadius: trackSize_,
backgroundColor: theme.videoSlider.val,
width,
justifyContent: "center",
},
]}
>
<Animated.View
style={[
{
position: "absolute",
height: trackSize_,
backgroundColor: theme.videoSliderFilled.val,
borderRadius: trackSize_ / 2,
},
progressStyle,
]}
/>
<PanGestureHandler
ref={panRef}
onGestureEvent={onGestureEvent}
simultaneousHandlers={tapRef}
>
<Animated.View
style={[
{
justifyContent: "center",
alignItems: "center",
height: knobSize_,
width: knobSize_,
borderRadius: knobSize_ / 2,
backgroundColor: theme.videoSliderFilled.val,
},
scrollTranslationStyle,
]}
/>
</PanGestureHandler>
</View>
</View>
</TapGestureHandler>
);
};
export default VideoSlider;

View File

@@ -0,0 +1,157 @@
import type { SheetProps, ViewProps } from "tamagui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
ScrollView,
Separator,
Sheet,
Spinner,
styled,
Text,
View,
} from "tamagui";
const PlayerText = styled(Text, {
color: "$playerSettingsUnactiveText",
fontWeight: "bold",
fontSize: 18,
});
function SettingsSheet(props: SheetProps) {
return (
<Sheet
snapPoints={[90]}
dismissOnSnapToBottom
modal
animation="spring"
{...props}
>
{props.children}
</Sheet>
);
}
function SettingsSheetOverlay() {
return (
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.7)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
);
}
function SettingsSheetHandle() {
return <Sheet.Handle backgroundColor="$sheetHandle" />;
}
function SettingsSheetFrame({
children,
isLoading,
}: {
children: React.ReactNode;
isLoading?: boolean;
}) {
return (
<View style={{ flex: 1 }} backgroundColor="black">
<Sheet.Frame
backgroundColor="$playerSettingsBackground"
padding="$5"
gap="$4"
>
{isLoading && (
<Spinner
size="large"
color="$loadingIndicator"
style={{
position: "absolute",
}}
/>
)}
{!isLoading && children}
</Sheet.Frame>
</View>
);
}
function SettingsHeader({
icon,
title,
rightButton,
}: {
icon: React.ReactNode;
title: string;
rightButton?: React.ReactNode;
}) {
const insets = useSafeAreaInsets();
return (
<>
<View
style={{ paddingLeft: insets.left, paddingRight: insets.right }}
flexDirection="row"
alignItems="center"
gap="$4"
>
{icon}
<PlayerText flexGrow={1}>{title}</PlayerText>
{rightButton}
</View>
<Separator />
</>
);
}
function SettingsContent({
isScroll = true,
children,
}: {
isScroll?: boolean;
children: React.ReactNode;
}) {
const ViewDisplay = isScroll ? ScrollView : View;
const insets = useSafeAreaInsets();
return (
<ViewDisplay
style={{ paddingLeft: insets.left, paddingRight: insets.right }}
contentContainerStyle={{
gap: "$4",
}}
>
{children}
</ViewDisplay>
);
}
function SettingsItem({
iconLeft,
iconRight,
title,
...props
}: ViewProps & {
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
title: string;
}) {
return (
<View flexDirection="row" gap="$4" alignItems="center" {...props}>
{iconLeft}
<PlayerText flexGrow={1} fontSize={16} fontWeight="700">
{title}
</PlayerText>
{iconRight}
</View>
);
}
export const Settings = {
Sheet: SettingsSheet,
SheetOverlay: SettingsSheetOverlay,
SheetHandle: SettingsSheetHandle,
SheetFrame: SettingsSheetFrame,
Header: SettingsHeader,
Content: SettingsContent,
Text: PlayerText,
Item: SettingsItem,
};

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