Compare commits

..

424 Commits

Author SHA1 Message Date
Adrian Castro
0ff855ce2a Merge 8f5d0247bb into a3f184979e 2024-04-06 20:27:59 +00: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
161 changed files with 25279 additions and 1146 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"
}

View File

@@ -4,7 +4,7 @@
"packageRules": [ "packageRules": [
{ {
"matchPackagePatterns": ["^@movie-web/"], "matchPackagePatterns": ["^@movie-web/"],
"enabled": false "enabled": true
} }
], ],
"updateInternalDeps": true, "updateInternalDeps": true,

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: 8
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: 8
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

View File

@@ -7,6 +7,7 @@ on:
permissions: permissions:
contents: write contents: write
pull-requests: write
jobs: jobs:
build-android: build-android:
@@ -16,40 +17,47 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Node.js - uses: pnpm/action-setup@v3
uses: actions/setup-node@v4
with:
node-version: 21
- uses: pnpm/action-setup@v2
name: Install pnpm name: Install pnpm
with: with:
version: 8 version: 8
run_install: false run_install: false
- name: Set up JDK 17 - name: Install Node.js
uses: actions/setup-java@v3 uses: actions/setup-node@v4
with: with:
java-version: '17' node-version: 21
distribution: 'temurin' cache: "pnpm"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 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 - name: Install dependencies
run: pnpm install run: pnpm install
- name: Build Android app - name: Setup Gradle
run: cd apps/expo && pnpm run apk uses: gradle/actions/setup-gradle@v3
- name: Rename apk - name: Build Android app
run: cd apps/expo && mv android/app/build/outputs/apk/release/app-release.apk android/app/build/outputs/apk/release/movie-web.apk run: cd apps/expo && pnpm apk
- name: Upload movie-web.apk as artifact - name: Upload movie-web.apk as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: apk name: apk
path: ./apps/expo/android/app/build/outputs/apk/release/movie-web.apk path: ./apps/expo/android/app/build/movie-web.apk
build-ios: build-ios:
runs-on: macos-14 runs-on: macos-14
@@ -58,35 +66,35 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Xcode Select Version - uses: pnpm/action-setup@v3
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.1.0'
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 21
- uses: pnpm/action-setup@v2
name: Install pnpm name: Install pnpm
with: with:
version: 8 version: 8
run_install: false 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 - name: Install dependencies
run: pnpm install run: pnpm install
- name: Build iOS app - name: Cache Pods
run: cd apps/expo && pnpm run ipa uses: actions/cache@v4
with:
path: apps/expo/ios
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Export .ipa from .app - name: Build iOS app
run: | run: cd apps/expo && pnpm ipa
cd apps/expo
mkdir -p ios/build/Build/Products/Release-iphoneos/Payload
mv ios/build/Build/Products/Release-iphoneos/movieweb.app ios/build/Build/Products/Release-iphoneos/Payload/
cd ios/build/Build/Products/Release-iphoneos
zip -r ../../../movie-web.ipa Payload
- name: Upload movie-web.ipa as artifact - name: Upload movie-web.ipa as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -4,27 +4,29 @@ on:
push: push:
branches: branches:
- master - master
workflow_dispatch:
permissions: permissions:
contents: write contents: write
jobs: jobs:
bump-version: bump-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Automated Version Bump - name: Automated Version Bump
uses: phips28/gh-action-bump-version@v10.1.1 uses: phips28/gh-action-bump-version@v11.0.0
with: with:
skip-tag: 'true' skip-tag: "true"
commit-message: 'chore: bump mobile version to {{version}} [skip ci]' commit-message: "chore: bump mobile version to {{version}} [skip ci]"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PACKAGEJSON_DIR: "apps/expo"
build-android: build-android:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -34,40 +36,50 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Pull version bump
run: git pull --all
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 21 node-version: 21
cache: "pnpm"
- uses: pnpm/action-setup@v2
name: Install pnpm
with:
version: 8
run_install: false
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: "17"
distribution: 'temurin' distribution: "temurin"
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 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 - name: Install dependencies
run: pnpm install run: pnpm install
- name: Build Android app - name: Setup Gradle
run: cd apps/expo && pnpm run apk uses: gradle/actions/setup-gradle@v3
- name: Rename apk - name: Build Android app
run: cd apps/expo && mv android/app/build/outputs/apk/release/app-release.apk android/app/build/outputs/apk/release/movie-web.apk run: cd apps/expo && pnpm apk
- name: Upload movie-web.apk as artifact - name: Upload movie-web.apk as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: apk name: apk
path: ./apps/expo/android/app/build/outputs/apk/release/movie-web.apk path: ./apps/expo/android/app/build/movie-web.apk
build-ios: build-ios:
runs-on: macos-14 runs-on: macos-14
@@ -77,35 +89,38 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Xcode Select Version - name: Pull version bump
uses: maxim-lobanov/setup-xcode@v1 run: git pull --all
with:
xcode-version: '15.1.0'
- name: Install Node.js - uses: pnpm/action-setup@v3
uses: actions/setup-node@v4
with:
node-version: 21
- uses: pnpm/action-setup@v2
name: Install pnpm name: Install pnpm
with: with:
version: 8 version: 8
run_install: false 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 - name: Install dependencies
run: pnpm install run: pnpm install
- name: Build iOS app - name: Cache Pods
run: cd apps/expo && pnpm run ipa uses: actions/cache@v4
with:
path: apps/expo/ios
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Export .ipa from .app - name: Build iOS app
run: | run: cd apps/expo && pnpm ipa
cd apps/expo
mkdir -p ios/build/Build/Products/Release-iphoneos/Payload
mv ios/build/Build/Products/Release-iphoneos/movieweb.app ios/build/Build/Products/Release-iphoneos/Payload/
cd ios/build/Build/Products/Release-iphoneos
zip -r ../../../movie-web.ipa Payload
- name: Upload movie-web.ipa as artifact - name: Upload movie-web.ipa as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -121,6 +136,9 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Pull version bump
run: git pull --all
- name: Download build artifacts - name: Download build artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@@ -129,6 +147,8 @@ jobs:
- name: Get package version - name: Get package version
id: package-version id: package-version
uses: martinbeentjes/npm-get-version-action@v1.3.1 uses: martinbeentjes/npm-get-version-action@v1.3.1
with:
path: apps/expo
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
@@ -137,7 +157,37 @@ jobs:
files: | files: |
movie-web.apk movie-web.apk
movie-web.ipa movie-web.ipa
generate_release_notes: true
fail_on_unmatched_files: true fail_on_unmatched_files: true
token: ${{ env.GITHUB_TOKEN }} token: ${{ env.GITHUB_TOKEN }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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"

5
.gitignore vendored
View File

@@ -19,6 +19,8 @@ expo-env.d.ts
apps/expo/.gitignore apps/expo/.gitignore
ios/ ios/
android/ android/
!modules/*/ios/
!modules/*/android/
# production # production
build build
@@ -45,3 +47,6 @@ yarn-error.log*
# turbo # turbo
.turbo .turbo
# tamagui
.tamagui

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"
},
]
}

View File

@@ -21,5 +21,11 @@
"typescript.preferences.autoImportFileExcludePatterns": [ "typescript.preferences.autoImportFileExcludePatterns": [
// Should import Text from UI components instead // Should import Text from UI components instead
"react-native/Libraries/Text/Text.d.ts" "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,8 +1,32 @@
# movie-web native-app # movie-web native-app
<!---
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 ## About
It uses [Turborepo](https://turborepo.org) and contains: This repository uses [Turborepo](https://turborepo.org) and contains:
```text ```text
.github .github
@@ -15,56 +39,37 @@ apps
├─ Expo SDK 50 ├─ Expo SDK 50
├─ React Native using React 18 ├─ React Native using React 18
├─ Navigation using Expo Router ├─ Navigation using Expo Router
Tailwind using Nativewind Styling with Tamagui
└─ Typesafe API calls using tRPC
packages packages
├─ api
| └─ Typesafe API calls to the backend
├─ tmdb ├─ tmdb
└─ Typesafe API calls to The Movie Database | └─ Typesafe API calls to The Movie Database
└─ provider-utils
└─ Typesafe API calls to the video providers
tooling tooling
├─ color
| └─ shared color palette
├─ eslint ├─ eslint
| └─ shared, fine-grained, eslint presets | └─ shared, fine-grained, eslint presets
├─ prettier ├─ prettier
| └─ shared prettier configuration | └─ shared prettier configuration
├─ tailwind
| └─ shared tailwind configuration
└─ typescript └─ typescript
└─ shared tsconfig you can extend from └─ shared tsconfig you can extend from
``` ```
### Configure Expo `dev`-script ## Getting started
#### Use iOS Simulator ### When it's time to add a new package
1. Make sure you have XCode and XCommand Line Tools installed [as shown on expo docs](https://docs.expo.dev/workflow/ios-simulator). 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).
> **NOTE:** If you just installed XCode, or if you have updated it, you need to open the simulator manually once. Run `npx expo start` in the root dir, and then enter `I` to launch Expo Go. After the manual launch, you can run `pnpm dev` in the root directory. 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
```diff created, you're ready to go build out the package.
+ "dev": "expo start --ios",
```
2. Run `pnpm dev` at the project root folder.
#### Use Android Emulator
1. Install Android Studio tools [as shown on expo docs](https://docs.expo.dev/workflow/android-studio-emulator).
2. Change the `dev` script at `apps/expo/package.json` to open the Android emulator.
```diff
+ "dev": "expo start --android",
```
3. Run `pnpm dev` at the project root folder.
> **TIP:** It might be easier to run each app in separate terminal windows so you get the logs from each app separately. This is also required if you want your terminals to be interactive, e.g. to access the Expo QR code. You can run `pnpm --filter expo dev` and `pnpm --filter nextjs dev` to run each app in a separate terminal window.
### 3. 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 ### References
This app is based on [create-t3-turbo](https://github.com/t3-oss/create-t3-turbo) and [Turborepo](https://turborepo.org). 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 @@
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": []
}

View File

@@ -1,17 +1,20 @@
import type { ExpoConfig } from "expo/config"; import type { ExpoConfig } from "expo/config";
import { version } from "./package.json";
import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement";
import withRNBackgroundDownloader from "./src/plugins/withRNBackgroundDownloader";
const defineConfig = (): ExpoConfig => ({ const defineConfig = (): ExpoConfig => ({
name: "movie-web", name: "movie-web",
slug: "mw-mobile", slug: "mw-mobile",
scheme: "dev.movieweb.app", scheme: "movieweb",
version: "0.1.0", version,
orientation: "portrait",
icon: "./assets/images/icon.png", icon: "./assets/images/icon.png",
userInterfaceStyle: "automatic", userInterfaceStyle: "automatic",
splash: { splash: {
image: "./assets/images/splash.png", image: "./assets/images/splash.png",
resizeMode: "contain", resizeMode: "contain",
backgroundColor: "#ffffff", backgroundColor: "#000000",
}, },
updates: { updates: {
fallbackToCacheTimeout: 0, fallbackToCacheTimeout: 0,
@@ -20,28 +23,73 @@ const defineConfig = (): ExpoConfig => ({
ios: { ios: {
bundleIdentifier: "dev.movieweb.app", bundleIdentifier: "dev.movieweb.app",
supportsTablet: true, supportsTablet: true,
requireFullScreen: true,
infoPlist: {
CFBundleName: "movie-web",
NSPhotoLibraryUsageDescription:
"This app saves videos to the photo library.",
NSAppTransportSecurity: {
NSAllowsArbitraryLoads: true,
},
},
}, },
android: { android: {
package: "dev.movieweb.app", package: "dev.movieweb.app",
adaptiveIcon: { permissions: ["WRITE_SETTINGS"],
foregroundImage: "./assets/images/adaptive-icon.png",
backgroundColor: "#FFFFFF",
},
}, },
web: { web: {
favicon: "./assets/images/favicon.png", favicon: "./assets/images/favicon.png",
bundler: "metro", bundler: "metro",
}, },
// extra: {
// eas: {
// projectId: "your-eas-project-id",
// },
// },
experiments: { experiments: {
tsconfigPaths: true, tsconfigPaths: true,
typedRoutes: true, typedRoutes: true,
}, },
plugins: ["expo-router"], plugins: [
"expo-router",
[withRemoveiOSNotificationEntitlement as unknown as string],
[withRNBackgroundDownloader 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,
},
],
],
}); });
export default defineConfig; export default defineConfig;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

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.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -2,10 +2,20 @@
module.exports = function (api) { module.exports = function (api) {
api.cache(true); api.cache(true);
return { return {
presets: [ presets: ["babel-preset-expo"],
["babel-preset-expo", { jsxImportSource: "nativewind" }], plugins: [
"nativewind/babel", "@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",
},
},
],
], ],
plugins: ["react-native-reanimated/plugin"],
}; };
}; };

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

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

View File

@@ -1,19 +1,19 @@
// Learn more: https://docs.expo.dev/guides/monorepos/ // Learn more: https://docs.expo.dev/guides/monorepos/
const { getDefaultConfig } = require("expo/metro-config"); const { getDefaultConfig } = require("expo/metro-config");
const { FileStore } = require("metro-cache"); const { FileStore } = require("metro-cache");
const { withNativeWind } = require("nativewind/metro"); const { withTamagui } = require("@tamagui/metro-plugin");
const path = require("path"); const path = require("path");
module.exports = withTurborepoManagedCache( module.exports = withTurborepoManagedCache(
withMonorepoPaths( withMonorepoPaths(
withNativeWind( withTamagui(
getDefaultConfig(__dirname, { getDefaultConfig(__dirname, {
isCSSEnabled: true, isCSSEnabled: true,
}), }),
{ {
input: "./src/app/styles/global.css", components: ["tamagui"],
configPath: "./tailwind.config.ts", config: "./tamagui.config.ts",
}, },
), ),
), ),

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");

View File

@@ -1,8 +1,8 @@
{ {
"name": "@movie-web/mobile", "name": "@movie-web/mobile",
"version": "0.1.0", "version": "0.0.1",
"private": true, "private": true,
"main": "expo-router/entry", "main": "index.js",
"scripts": { "scripts": {
"clean": "git clean -xdf .expo .turbo node_modules", "clean": "git clean -xdf .expo .turbo node_modules",
"dev": "expo start", "dev": "expo start",
@@ -10,36 +10,73 @@
"dev:ios": "expo start -c --ios", "dev:ios": "expo start -c --ios",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"apk": "expo prebuild --platform=android && cd android && ./gradlew assembleRelease", "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 -workspace movieweb.xcworkspace -scheme movieweb -sdk iphoneos -configuration Release -derivedDataPath build -destination generic/platform=iOS CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO", "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 -workspace movieweb.xcworkspace -scheme movieweb -sdk iphonesimulator -configuration Release -derivedDataPath build -destination \"generic/platform=iOS Simulator\" CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO", "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", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@expo/metro-config": "^0.17.3", "@expo/metro-config": "^0.17.3",
"@kesha-antonov/react-native-background-downloader": "^3.1.2",
"@movie-web/api": "*",
"@movie-web/colors": "*",
"@movie-web/provider-utils": "*",
"@movie-web/tmdb": "*", "@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", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "expo": "~50.0.14",
"expo": "~50.0.5", "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-constants": "~15.4.5", "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-linking": "~6.2.2",
"expo-router": "~3.4.6", "expo-media-library": "~15.9.1",
"expo-navigation-bar": "^2.8.1",
"expo-network": "~5.8.0",
"expo-router": "~3.4.8",
"expo-screen-orientation": "~6.4.1",
"expo-splash-screen": "~0.26.4", "expo-splash-screen": "~0.26.4",
"expo-status-bar": "~1.11.1", "expo-status-bar": "~1.11.1",
"expo-system-ui": "^2.9.3",
"expo-web-browser": "^12.8.2", "expo-web-browser": "^12.8.2",
"nativewind": "~4.0.23", "ffmpeg-kit-react-native": "^6.0.2",
"immer": "^10.0.3",
"iso-639-1": "^3.1.2",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.73.2", "react-native": "0.73.6",
"react-native-css-interop": "~0.0.22", "react-native-context-menu-view": "^1.14.1",
"react-native-gesture-handler": "~2.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-reanimated": "~3.6.2",
"react-native-safe-area-context": "~4.8.2", "react-native-safe-area-context": "~4.8.2",
"react-native-screens": "~3.29.0", "react-native-screens": "~3.29.0",
"react-native-svg": "14.1.0",
"react-native-web": "^0.19.10", "react-native-web": "^0.19.10",
"tailwind-merge": "^2.2.1" "subsrt-ts": "^2.1.2",
"tamagui": "^1.94.0",
"zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.9", "@babel/core": "^7.23.9",
@@ -47,14 +84,14 @@
"@babel/runtime": "^7.23.9", "@babel/runtime": "^7.23.9",
"@movie-web/eslint-config": "workspace:^0.2.0", "@movie-web/eslint-config": "workspace:^0.2.0",
"@movie-web/prettier-config": "workspace:^0.1.0", "@movie-web/prettier-config": "workspace:^0.1.0",
"@movie-web/tailwind-config": "workspace:^0.1.0",
"@movie-web/tsconfig": "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/babel__core": "^7.20.5",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"babel-plugin-module-resolver": "^5.0.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"tailwindcss": "^3.4.0", "typescript": "^5.4.3"
"typescript": "^5.3.3"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,

View File

@@ -1,24 +1,39 @@
import { View } from "react-native"; import { Platform } from "react-native";
import * as Haptics from "expo-haptics";
import { Tabs } from "expo-router"; import { Tabs } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useTheme, View } from "tamagui";
import Colors from "@movie-web/tailwind-config/colors"; import { MovieWebSvg } from "~/components/Icon";
import SvgTabBarIcon from "~/components/SvgTabBarIcon";
import TabBarIcon from "~/components/TabBarIcon"; import TabBarIcon from "~/components/TabBarIcon";
export default function TabLayout() { export default function TabLayout() {
const theme = useTheme();
return ( return (
<Tabs <Tabs
sceneContainerStyle={{ sceneContainerStyle={{
backgroundColor: Colors.background, backgroundColor: theme.screenBackground.val,
}} }}
screenListeners={() => ({
tabPress: () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
focus: () => {
void ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
},
})}
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
tabBarActiveTintColor: Colors.primary[100], tabBarActiveTintColor: theme.tabBarIconFocused.val,
tabBarStyle: { tabBarStyle: {
backgroundColor: Colors.secondary[700], backgroundColor: theme.tabBarBackground.val,
borderTopColor: "transparent", borderTopColor: "transparent",
borderTopRightRadius: 20, borderTopRightRadius: 20,
borderTopLeftRadius: 20, borderTopLeftRadius: 20,
paddingBottom: Platform.select({ ios: 100 }),
height: 80, height: 80,
}, },
tabBarItemStyle: { tabBarItemStyle: {
@@ -42,11 +57,11 @@ export default function TabLayout() {
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="about" name="downloads"
options={{ options={{
title: "About", title: "Downloads",
tabBarIcon: ({ focused }) => ( tabBarIcon: ({ focused }) => (
<TabBarIcon name="info-circle" focused={focused} /> <TabBarIcon name="download" focused={focused} />
), ),
}} }}
/> />
@@ -55,13 +70,35 @@ export default function TabLayout() {
options={{ options={{
title: "Search", title: "Search",
tabBarLabel: "", tabBarLabel: "",
tabBarIcon: () => ( tabBarIcon: ({ focused }) => (
<View className="android:top-2 ios:top-2 flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-primary-400 text-center align-middle text-2xl text-white"> <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" /> <TabBarIcon name="search" color="#FFF" />
</View> </View>
), ),
}} }}
/> />
<Tabs.Screen
name="movie-web"
options={{
title: "movie-web",
tabBarIcon: ({ focused }) => (
<SvgTabBarIcon focused={focused}>
<MovieWebSvg />
</SvgTabBarIcon>
),
}}
/>
<Tabs.Screen <Tabs.Screen
name="settings" name="settings"
options={{ options={{
@@ -71,15 +108,6 @@ export default function TabLayout() {
), ),
}} }}
/> />
<Tabs.Screen
name="account"
options={{
title: "Account",
tabBarIcon: ({ focused }) => (
<TabBarIcon name="user" focused={focused} />
),
}}
/>
</Tabs> </Tabs>
); );
} }

View File

@@ -1,16 +0,0 @@
import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text";
export default function AboutScreen() {
return (
<ScreenLayout
title="About"
subtitle="What is movie-web and how content is served?"
>
<Text>
No content is served from movie-web directly and movie web does not host
anything.
</Text>
</ScreenLayout>
);
}

View File

@@ -1,13 +0,0 @@
import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text";
export default function AccountScreen() {
return (
<ScreenLayout
title="Account"
subtitle="Manage your movie web account from here"
>
<Text>Hey Bro! what are you up to?</Text>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,142 @@
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 } 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 DownloadsScreen: React.FC = () => {
const { startDownload } = useDownloadManager();
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();
const theme = useTheme();
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",
});
};
const exampleShowMedia: ScrapeMedia = {
type: "show",
title: "Example Show Title",
releaseYear: 2022,
imdbId: "tt1234567",
tmdbId: "12345",
season: {
number: 1,
tmdbId: "54321",
},
episode: {
number: 3,
tmdbId: "98765",
},
};
return (
<ScreenLayout>
<YStack gap={2} style={{ padding: 10 }}>
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name="download"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={async () => {
await startDownload(
"https://samplelib.com/lib/preview/mp4/sample-5s.mp4",
"mp4",
exampleShowMedia,
).catch(console.error);
}}
>
test download (mp4)
</MWButton>
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name="download"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={async () => {
await startDownload(
"http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8",
"hls",
{
...exampleShowMedia,
tmdbId: "123456",
},
).catch(console.error);
}}
>
test download (hls)
</MWButton>
</YStack>
<ScrollView
contentContainerStyle={{
gap: "$4",
}}
>
{/* TODO: Differentiate movies/shows, shows in new page */}
{downloads
.map((item) => item.downloads)
.flat()
.map((item) => (
<DownloadItem
key={item.id}
item={item}
onPress={() => handlePress(item.localPath)}
/>
))}
</ScrollView>
</ScreenLayout>
);
};
export default DownloadsScreen;

View File

@@ -1,10 +1,23 @@
import React from "react";
import { View } from "tamagui";
import { ItemListSection } from "~/components/item/ItemListSection";
import ScreenLayout from "~/components/layout/ScreenLayout"; import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text"; import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
export default function HomeScreen() { export default function HomeScreen() {
const { bookmarks } = useBookmarkStore();
const { watchHistory } = useWatchHistoryStore();
return ( return (
<ScreenLayout title="Home" subtitle="This is where all magic happens"> <View style={{ flex: 1 }} flex={1}>
<Text>Movies will be listed here</Text> <ScreenLayout>
</ScreenLayout> <ItemListSection title="Bookmarks" items={bookmarks} />
<ItemListSection
title="Continue Watching"
items={watchHistory.map((x) => x.item)}
/>
</ScreenLayout>
</View>
); );
} }

View File

@@ -0,0 +1,5 @@
import ScreenLayout from "~/components/layout/ScreenLayout";
export default function MovieWebScreen() {
return <ScreenLayout></ScreenLayout>;
}

View File

@@ -0,0 +1,161 @@
import React, { useEffect, useState } from "react";
import { Keyboard, ScrollView } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { View, XStack } 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 - 110), // 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 (
<ScreenLayout>
<XStack flex={1}>
<ScrollView
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>
</ScrollView>
<Animated.View
style={[
{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
},
animatedStyle,
]}
>
<SearchBar onSearchChange={setQuery} />
</Animated.View>
</XStack>
</ScreenLayout>
);
}
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

@@ -1,49 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { useFocusEffect } from "expo-router";
import { FontAwesome5 } from "@expo/vector-icons";
import Colors from "@movie-web/tailwind-config/colors";
export default function Searchbar({
onSearchChange,
}: {
onSearchChange: (text: string) => void;
}) {
const [keyword, setKeyword] = useState("");
const inputRef = useRef<TextInput>(null);
useFocusEffect(
useCallback(() => {
// When the screen is focused
const focus = () => {
setTimeout(() => {
inputRef?.current?.focus();
}, 20);
};
focus();
return focus; // cleanup
}, []),
);
const handleChange = (text: string) => {
setKeyword(text);
onSearchChange(text);
};
return (
<View className="mb-6 mt-4 flex-row items-center rounded-full border border-primary-400 focus-within:border-primary-300">
<View className="ml-1 w-12 items-center justify-center">
<FontAwesome5 name="search" size={18} color={Colors.secondary[200]} />
</View>
<TextInput
value={keyword}
onChangeText={handleChange}
ref={inputRef}
placeholder="What are you looking for?"
placeholderTextColor={Colors.secondary[200]}
className="w-full rounded-3xl py-3 pr-5 text-white focus-visible:outline-none"
/>
</View>
);
}

View File

@@ -1,59 +0,0 @@
import React, { useState } from "react";
import { ScrollView, View } from "react-native";
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 { Text } from "~/components/ui/Text";
import Searchbar from "./Searchbar";
export default function SearchScreen() {
const [searchResults, setSearchResults] = useState<ItemData[]>([]);
const handleSearchChange = async (query: string) => {
if (query.length > 0) {
const results = await fetchSearchResults(query).catch(() => []);
setSearchResults(results);
} else {
setSearchResults([]);
}
};
return (
<ScrollView>
<ScreenLayout
title={
<View className="flex-row items-center">
<Text className="text-2xl font-bold">Search</Text>
</View>
}
subtitle="Looking for something?"
>
<Searchbar onSearchChange={handleSearchChange} />
<View className="flex w-full flex-1 flex-row flex-wrap justify-start">
{searchResults.map((item, index) => (
<View key={index} className="basis-1/2 px-3 pb-3">
<Item data={item} />
</View>
))}
</View>
</ScreenLayout>
</ScrollView>
);
}
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),
year: new Date(
result.media_type === "tv" ? result.first_air_date : result.release_date,
).getFullYear(),
type: result.media_type,
}));
}

View File

@@ -1,10 +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 ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text"; 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() { 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 ( return (
<ScreenLayout title="Settings" subtitle="Need to change something?"> <ScreenLayout>
<Text>Settings would be listed in here. Coming soon</Text> <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.buttonSecondaryText.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.buttonSecondaryText.val}
/>
}
onPress={() => clearCacheDirectory()}
>
Clear Cache
</MWButton>
</XStack>
</YStack>
</YStack>
</YStack>
</View>
<UpdateSheet
markdownContent={updateMarkdownContent}
open={showUpdateSheet}
setShowUpdateSheet={setShowUpdateSheet}
downloadUrl={downloadUrl}
/>
</ScreenLayout> </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.buttonSecondaryText.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

@@ -1,19 +1,23 @@
import { View } from "react-native"; import * as Linking from "expo-linking";
import { Link, Stack } from "expo-router"; import { Link, Stack } from "expo-router";
import { Text, View } from "tamagui";
import { Text } from "~/components/ui/Text";
export default function NotFoundScreen() { export default function NotFoundScreen() {
if (Linking.useURL()) return null;
return ( return (
<> <>
<Stack.Screen options={{ title: "Oops!" }} /> <Stack.Screen options={{ title: "Oops!" }} />
<View className="flex-1 items-center justify-center p-5"> <View flex={1} alignItems="center" justifyContent="center" padding={5}>
<Text className="text-lg font-bold"> <Text fontWeight="bold">This screen doesn&apos;t exist.</Text>
This screen doesn&apos;t exist.
</Text>
<Link href="/" className="mt-4 py-4"> <Link
<Text className="text-sm text-sky-500">Go to home screen!</Text> href="/"
style={{
marginTop: 16,
paddingVertical: 16,
}}
>
<Text color="skyblue">Go to home screen!</Text>
</Link> </Link>
</View> </View>
</> </>

View File

@@ -1,18 +1,18 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { useEffect } from "react"; import { useEffect } from "react";
import { useColorScheme } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useFonts } from "expo-font"; import { useFonts } from "expo-font";
import { SplashScreen, Stack } from "expo-router"; import { SplashScreen, Stack } from "expo-router";
import FontAwesome from "@expo/vector-icons/FontAwesome"; import FontAwesome from "@expo/vector-icons/FontAwesome";
import { import { DarkTheme, ThemeProvider } from "@react-navigation/native";
DarkTheme, import { ToastProvider, ToastViewport } from "@tamagui/toast";
DefaultTheme, import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
ThemeProvider, import { TamaguiProvider, Theme, useTheme } from "tamagui";
} from "@react-navigation/native"; import tamaguiConfig from "tamagui.config";
import Colors from "@movie-web/tailwind-config/colors"; import { useThemeStore } from "~/stores/theme";
// @ts-expect-error - Without named import it causes an infinite loop
import "./styles/global.css"; import _styles from "../../tamagui-web.css";
export { export {
// Catch any errors thrown by the Layout component. // Catch any errors thrown by the Layout component.
@@ -29,6 +29,8 @@ SplashScreen.preventAutoHideAsync().catch(() => {
/* reloading the app might trigger this, so it's safe to ignore */ /* reloading the app might trigger this, so it's safe to ignore */
}); });
const queryClient = new QueryClient();
export default function RootLayout() { export default function RootLayout() {
const [loaded, error] = useFonts({ const [loaded, error] = useFonts({
OpenSansRegular: require("../../assets/fonts/OpenSans-Regular.ttf"), OpenSansRegular: require("../../assets/fonts/OpenSans-Regular.ttf"),
@@ -57,25 +59,60 @@ export default function RootLayout() {
return null; return null;
} }
return <RootLayoutNav />; 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() { function RootLayoutNav() {
const colorScheme = useColorScheme(); const themeStore = useThemeStore((s) => s.theme);
return ( return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}> <QueryClientProvider client={queryClient}>
<Stack <TamaguiProvider config={tamaguiConfig} defaultTheme="main">
screenOptions={{ <ToastProvider>
gestureEnabled: true, <ThemeProvider value={DarkTheme}>
headerShown: false, <Theme name={themeStore}>
contentStyle: { <ScreenStacks />
backgroundColor: Colors.background, </Theme>
}, </ThemeProvider>
}} <ToastViewport />
> </ToastProvider>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> </TamaguiProvider>
</Stack> </QueryClientProvider>
</ThemeProvider>
); );
} }

View File

@@ -1,36 +0,0 @@
import { Image, View } from "react-native";
import { Text } from "~/components/ui/Text";
export interface ItemData {
id: string;
title: string;
type: "movie" | "tv";
year: number;
posterUrl: string;
}
export default function Item({ data }: { data: ItemData }) {
const { title, type, year, posterUrl } = data;
return (
<View className="w-full">
<View className="mb-2 aspect-[9/14] w-full overflow-hidden rounded-2xl">
<Image
source={{
uri: posterUrl,
}}
className="h-full w-full"
/>
</View>
<Text className="font-bold">{title}</Text>
<View className="flex-row items-center gap-3">
<Text className="text-xs text-gray-600">
{type === "tv" ? "Show" : "Movie"}
</Text>
<View className="h-1 w-1 rounded-3xl bg-gray-600" />
<Text className="text-sm text-gray-600">{year}</Text>
</View>
</View>
);
}

View File

@@ -1,22 +0,0 @@
import { View } from "react-native";
import { Text } from "~/components/ui/Text";
interface Props {
title?: React.ReactNode | string;
subtitle?: string;
children?: React.ReactNode;
}
export default function ScreenLayout({ title, subtitle, children }: Props) {
return (
<View className="bg-shade-900 flex-1 p-12">
{typeof title === "string" && (
<Text className="text-2xl font-bold">{title}</Text>
)}
{typeof title !== "string" && title}
<Text className="mt-1 text-sm font-bold">{subtitle}</Text>
<View className="py-3">{children}</View>
</View>
);
}

View File

@@ -1,18 +0,0 @@
import type { TextProps } from "react-native";
import { Text as RNText } from "react-native";
import { cva } from "class-variance-authority";
import { cn } from "~/app/lib/utils";
const textVariants = cva("text-white");
export function Text({ className, ...props }: TextProps) {
return (
<RNText
className={cn(className, textVariants(), {
"font-sans": !className?.includes("font-"),
})}
{...props}
/>
);
}

View File

@@ -1,7 +0,0 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

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,138 @@
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 { Image, Text, View, XStack, YStack } from "tamagui";
import type { Download } from "~/hooks/useDownloadManager";
import { useDownloadManager } from "~/hooks/useDownloadManager";
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">
<XStack gap="$6" maxWidth="65%">
<Text fontWeight="$bold" ellipse flexGrow={1}>
{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>
);
}

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

@@ -1,17 +1,12 @@
import { FontAwesome } from "@expo/vector-icons"; import { FontAwesome } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import Colors from "@movie-web/tailwind-config/colors";
type Props = { type Props = {
focused?: boolean; focused?: boolean;
} & React.ComponentProps<typeof FontAwesome>; } & React.ComponentProps<typeof FontAwesome>;
export default function TabBarIcon({ focused, ...rest }: Props) { export default function TabBarIcon({ focused, ...rest }: Props) {
return ( const theme = useTheme();
<FontAwesome const color = focused ? theme.tabBarIconFocused.val : theme.tabBarIcon.val;
color={focused ? Colors.primary[300] : Colors.secondary[300]} return <FontAwesome color={color} size={24} {...rest} />;
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,149 @@
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";
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}
>
<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,56 @@
import { Linking } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
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() {
const insets = useSafeAreaInsets();
return (
<View
paddingTop={insets.top}
alignItems="center"
gap="$3"
flexDirection="row"
>
<BrandPill />
<Circle
backgroundColor="$pillBackground"
size="$4.5"
pressStyle={{
opacity: 1,
scale: 1.05,
}}
onPress={async () => {
await Linking.openURL(DISCORD_LINK);
}}
onLongPress={() =>
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}
>
<MaterialIcons name="discord" size={32} color="white" />
</Circle>
<Circle
backgroundColor="$pillBackground"
size="$4.5"
pressStyle={{
opacity: 1,
scale: 1.05,
}}
onPress={async () => {
await Linking.openURL(GITHUB_LINK);
}}
onLongPress={() =>
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}
>
<FontAwesome6 name="github" size={32} color="white" />
</Circle>
</View>
);
}

View File

@@ -0,0 +1,38 @@
import { ScrollView } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient";
import { Header } from "./Header";
interface Props {
children?: React.ReactNode;
}
export default function ScreenLayout({ children }: Props) {
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}
>
<Header />
<ScrollView
marginTop="$4"
flexGrow={1}
showsVerticalScrollIndicator={false}
>
{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.buttonSecondaryText.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,97 @@
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.buttonSecondaryText.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 { findHighestQuality } 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 = findHighestQuality(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.buttonSecondaryText.val}
/>
}
onPress={() =>
url &&
startDownload(
url,
stream?.type === "hls" ? "hls" : "mp4",
scrapeMedia,
).catch(console.error)
}
>
Download
</MWButton>
</Controls>
</>
);
};

View File

@@ -0,0 +1,48 @@
import { Text, View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
import { BrandPill } from "../BrandPill";
import { BackButton } from "./BackButton";
import { Controls } from "./Controls";
const mapSeasonAndEpisodeNumberToText = (season: number, episode: number) => {
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
};
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,232 @@
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 {
HlsBasedStream,
RunOutput,
ScrapeMedia,
} from "@movie-web/provider-utils";
import {
constructFullUrl,
extractTracksFromHLS,
findHighestQuality,
} from "@movie-web/provider-utils";
import type { ItemData } from "../item/item";
import type { AudioTrack } from "./AudioTrackSelector";
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);
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 highestQuality = findHighestQuality(streamResult.stream);
const url = highestQuality
? streamResult.stream.qualities[highestQuality]?.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) {
const audioTracks: AudioTrack[] = tracks.audio.map((track) => ({
uri: constructFullUrl(
(streamResult?.stream as HlsBasedStream).playlist,
track.uri,
),
name: track.properties[0]?.attributes.name?.toString() ?? "Unknown",
language:
track.properties[0]?.attributes.language?.toString() ?? "Unknown",
active: Boolean(track.properties[0]?.attributes.default) ?? false,
}));
const uniqueTracks = new Set(audioTracks.map((t) => t.language));
const filteredAudioTracks = audioTracks.filter((track) => {
if (uniqueTracks.has(track.language)) {
uniqueTracks.delete(track.language);
return true;
}
return false;
});
setAudioTracks(filteredAudioTracks);
}
}
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.buttonSecondaryText.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.buttonSecondaryText.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.buttonSecondaryText.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.buttonSecondaryText.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.buttonSecondaryText.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,383 @@
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 { useRouter } from "expo-router";
import * as StatusBar from "expo-status-bar";
import { Feather } from "@expo/vector-icons";
import { Spinner, useTheme, View } from "tamagui";
import { findHighestQuality } 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 {
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 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);
let url = null;
if (stream.type === "hls") {
url = stream.playlist;
}
if (stream.type === "file") {
const highestQuality = findHighestQuality(stream);
url = highestQuality ? stream.qualities[highestQuality]?.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,
]);
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,
};

View File

@@ -0,0 +1,18 @@
export const mapMillisecondsToTime = (milliseconds: number): string => {
const hours = Math.floor(milliseconds / (1000 * 60 * 60));
const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((milliseconds % (1000 * 60)) / 1000);
const components: string[] = [];
if (hours > 0) {
components.push(hours.toString().padStart(2, "0"));
}
components.push(minutes.toString().padStart(2, "0"));
components.push(seconds.toString().padStart(2, "0"));
const formattedTime = components.join(":");
return formattedTime;
};

View File

@@ -0,0 +1,28 @@
import { Button, styled } from "tamagui";
export const MWButton = styled(Button, {
variants: {
type: {
primary: {
backgroundColor: "$buttonPrimaryBackground",
color: "$buttonPrimaryText",
fontWeight: "bold",
},
secondary: {
backgroundColor: "$buttonSecondaryBackground",
color: "$buttonSecondaryText",
fontWeight: "bold",
},
purple: {
backgroundColor: "$buttonPurpleBackground",
color: "white",
fontWeight: "bold",
},
cancel: {
backgroundColor: "$buttonCancelBackground",
color: "white",
fontWeight: "bold",
},
},
} as const,
});

View File

@@ -0,0 +1,26 @@
import { Input, styled } from "tamagui";
export const MWInput = styled(Input, {
fontWeight: "$semibold",
variants: {
type: {
default: {
backgroundColor: "$inputBackground",
color: "$inputText",
placeholderTextColor: "$placeHolderText",
borderColor: "$inputBorder",
outlineStyle: "none",
},
search: {
backgroundColor: "$searchBackground",
borderColor: "$colorTransparent",
placeholderTextColor: "$searchPlaceholder",
outlineStyle: "none",
focusStyle: {
borderColor: "$colorTransparent",
},
},
},
},
});

View File

@@ -0,0 +1,14 @@
import { Progress, styled, withStaticProperties } from "tamagui";
const MWProgressFrame = styled(Progress, {
backgroundColor: "$progressBackground",
});
const MWProgressIndicator = styled(Progress.Indicator, {
backgroundColor: "$progressFilled",
animation: "bounce",
});
export const MWProgress = withStaticProperties(MWProgressFrame, {
Indicator: MWProgressIndicator,
});

View File

@@ -0,0 +1,52 @@
import type { Input } from "tamagui";
import { useEffect, useRef, useState } from "react";
import { FontAwesome5 } from "@expo/vector-icons";
import { useIsFocused } from "@react-navigation/native";
import { useTheme, View } from "tamagui";
import { MWInput } from "./Input";
export function SearchBar({
onSearchChange,
}: {
onSearchChange: (text: string) => void;
}) {
const theme = useTheme();
const pageIsFocused = useIsFocused();
const [keyword, setKeyword] = useState("");
const inputRef = useRef<Input>(null);
useEffect(() => {
if (pageIsFocused) {
inputRef.current?.focus();
}
}, [pageIsFocused]);
const handleChange = (text: string) => {
setKeyword(text);
onSearchChange(text);
};
return (
<View
flexDirection="row"
alignItems="center"
borderRadius={999}
borderWidth={1}
backgroundColor={theme.searchBackground}
>
<View width={48} alignItems="center" justifyContent="center">
<FontAwesome5 name="search" size={18} color={theme.searchIcon.val} />
</View>
<MWInput
type="search"
value={keyword}
onChangeText={handleChange}
ref={inputRef}
placeholder="What are you looking for?"
width="75%"
backgroundColor={theme.searchBackground}
/>
</View>
);
}

View File

@@ -0,0 +1,38 @@
import { Select, styled, withStaticProperties } from "tamagui";
const MWSelectFrame = styled(Select, {
variants: {
type: {
default: {
backgroundColor: "$inputBackground",
color: "$inputText",
borderColor: "$inputBorder",
},
},
},
defaultVariants: {
type: "default",
},
});
const MWSelectTrigger = styled(Select.Trigger, {
variants: {
type: {
default: {
backgroundColor: "$inputBackground",
color: "$inputText",
placeholderTextColor: "$inputPlaceholderText",
borderColor: "$inputBorder",
},
},
},
defaultVariants: {
type: "default",
},
});
const MWSelect = withStaticProperties(MWSelectFrame, {
Trigger: MWSelectTrigger,
});
export { MWSelect };

View File

@@ -0,0 +1,14 @@
import { Separator, styled } from "tamagui";
export const MWSeparator = styled(Separator, {
variants: {
type: {
settings: {
borderColor: "$shade300",
},
},
},
defaultVariants: {
type: "settings",
},
});

View File

@@ -0,0 +1,27 @@
import type { SwitchProps, SwitchThumbProps } from "tamagui";
import { Switch, useTheme } from "tamagui";
const MWSwitch = (props: SwitchProps) => {
const theme = useTheme();
return (
<Switch
native
nativeProps={{
trackColor: {
true: theme.switchActiveTrackColor.val,
false: theme.switchInactiveTrackColor.val,
},
thumbColor: theme.switchThumbColor.val,
}}
{...props}
/>
);
};
const MWSwitchThumb = (props: SwitchThumbProps) => {
return <Switch.Thumb animation="bounce" {...props} />;
};
MWSwitch.Thumb = MWSwitchThumb;
export { MWSwitch };

View File

@@ -0,0 +1,36 @@
import type { TextProps } from "react-native";
import type { AnimatedProps } from "react-native-reanimated";
import { useEffect } from "react";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
export const FlashingText = (
props: AnimatedProps<TextProps> & {
isInProgress: boolean;
},
) => {
const opacity = useSharedValue(0);
useEffect(() => {
if (props.isInProgress) {
opacity.value = withRepeat(
withTiming(1, { duration: 1000, easing: Easing.ease }),
-1,
true,
);
} else {
opacity.value = 1;
}
}, [props.isInProgress, opacity]);
const style = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return <Animated.Text {...props} style={[props.style, style]} />;
};

View File

@@ -0,0 +1,2 @@
export const DISCORD_LINK = "https://movie-web.github.io/links/discord";
export const GITHUB_LINK = "https://github.com/movie-web";

View File

@@ -0,0 +1,88 @@
import type { Video } from "expo-av";
import { useCallback, useEffect } from "react";
import { Audio } from "expo-av";
import type { Stream } from "@movie-web/provider-utils";
import type { AudioTrack } from "~/components/player/AudioTrackSelector";
import { usePlayerStore } from "~/stores/player/store";
export const useAudioTrack = () => {
const videoRef = usePlayerStore((state) => state.videoRef);
const audioObject = usePlayerStore((state) => state.audioObject);
const currentAudioTrack = usePlayerStore((state) => state.currentAudioTrack);
const setAudioObject = usePlayerStore((state) => state.setAudioObject);
const setCurrentAudioTrack = usePlayerStore(
(state) => state.setCurrentAudioTrack,
);
const synchronizePlayback = useCallback(
async (selectedAudioTrack?: AudioTrack, stream?: Stream) => {
if (selectedAudioTrack && stream) {
if (audioObject) {
await audioObject.unloadAsync();
}
const createAudioAsyncWithTimeout = (uri: string, timeout = 5000) => {
return new Promise<Audio.Sound | undefined>((resolve, reject) => {
Audio.Sound.createAsync({
uri,
headers: {
...stream.headers,
...stream.preferredHeaders,
},
})
.then((value) => resolve(value.sound))
.catch(reject);
setTimeout(() => {
reject(new Error("Timeout: Audio loading took too long"));
}, timeout);
});
};
try {
const sound = await createAudioAsyncWithTimeout(
selectedAudioTrack.uri,
);
if (!sound) return;
setAudioObject(sound);
setCurrentAudioTrack(selectedAudioTrack);
} catch (error) {
console.error("Error loading audio track:", error);
}
} else {
if (audioObject) {
await audioObject.unloadAsync();
setAudioObject(null);
}
}
},
[audioObject, setAudioObject, setCurrentAudioTrack],
);
const synchronizeAudioWithVideo = async (
videoRef: Video | null,
audioObject: Audio.Sound | null,
selectedAudioTrack?: AudioTrack,
): Promise<void> => {
if (videoRef && audioObject) {
const videoStatus = await videoRef.getStatusAsync();
if (selectedAudioTrack && videoStatus.isLoaded) {
await videoRef.setIsMutedAsync(true);
await audioObject.playAsync();
await audioObject.setPositionAsync(videoStatus.positionMillis + 2000);
} else {
await videoRef.setIsMutedAsync(false);
}
}
};
useEffect(() => {
if (audioObject && currentAudioTrack) {
void synchronizeAudioWithVideo(videoRef, audioObject, currentAudioTrack);
}
}, [audioObject, videoRef, currentAudioTrack]);
return { synchronizePlayback };
};

View File

@@ -0,0 +1,24 @@
import { useCallback, useState } from "react";
import { useSharedValue } from "react-native-reanimated";
import * as Brightness from "expo-brightness";
export const useBrightness = () => {
const [showBrightnessOverlay, setShowBrightnessOverlay] = useState(false);
const brightness = useSharedValue(0.5);
const handleBrightnessChange = useCallback(async (newValue: number) => {
try {
await Brightness.setBrightnessAsync(newValue);
} catch (error) {
console.error("Failed to set brightness:", error);
}
}, []);
return {
showBrightnessOverlay,
setShowBrightnessOverlay,
brightness,
handleBrightnessChange,
} as const;
};

View File

@@ -0,0 +1,58 @@
import { useCallback } from "react";
import { transformSearchResultToScrapeMedia } from "@movie-web/provider-utils";
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
import { usePlayerStore } from "~/stores/player/store";
export const useMeta = () => {
const meta = usePlayerStore((state) => state.meta);
const setMeta = usePlayerStore((state) => state.setMeta);
const convertIdToMeta = useCallback(
async (id: string, type: "movie" | "tv") => {
const media = await fetchMediaDetails(id, type);
if (!media) return;
const scrapeMedia = transformSearchResultToScrapeMedia(
media.type,
media.result,
meta?.season?.number,
meta?.episode?.number,
);
let seasonData = null;
if (scrapeMedia.type === "show") {
seasonData = await fetchSeasonDetails(
scrapeMedia.tmdbId,
scrapeMedia.season.number,
);
}
const m = {
...scrapeMedia,
poster: media.result.poster_path,
...("season" in scrapeMedia
? {
season: {
number: scrapeMedia.season.number,
tmdbId: scrapeMedia.tmdbId,
},
episode: {
number: scrapeMedia.episode.number,
tmdbId: scrapeMedia.episode.tmdbId,
},
episodes:
seasonData?.episodes.map((e) => ({
tmdbId: e.id.toString(),
number: e.episode_number,
name: e.name,
})) ?? [],
}
: {}),
};
setMeta(m);
return m;
},
[meta?.episode?.number, meta?.season?.number, setMeta],
);
return { convertIdToMeta };
};

View File

@@ -0,0 +1,24 @@
import { useCallback } from "react";
import { usePlayerStore } from "~/stores/player/store";
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
export const usePlaybackSpeed = () => {
const videoRef = usePlayerStore((state) => state.videoRef);
const changePlaybackSpeed = useCallback(
async (newValue: number) => {
if (videoRef) {
await videoRef.setRateAsync(newValue, true);
}
},
[videoRef],
);
return {
speeds,
currentSpeed: videoRef?.props.rate ?? 1,
changePlaybackSpeed,
} as const;
};

View File

@@ -0,0 +1,21 @@
import { useCallback } from "react";
import * as ScreenOrientation from "expo-screen-orientation";
export const usePlayer = () => {
const presentFullscreenPlayer = useCallback(async () => {
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.LANDSCAPE,
);
}, []);
const dismissFullscreenPlayer = useCallback(async () => {
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}, []);
return {
presentFullscreenPlayer,
dismissFullscreenPlayer,
} as const;
};

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