Compare commits
559 Commits
master
...
6c189d504b
Author | SHA1 | Date | |
---|---|---|---|
|
6c189d504b | ||
|
118fa2092d | ||
|
5ef90f52a3 | ||
|
d7940766e7 | ||
|
e3d507db72 | ||
|
b12562d249 | ||
|
100435af3c | ||
|
7cce25a261 | ||
|
a89ef8a901 | ||
|
ef97313fb9 | ||
|
932dcddfc0 | ||
|
ac4e5cc6bd | ||
|
0820e5b7c7 | ||
|
1e7f3b9dc0 | ||
|
59f27b0397 | ||
|
c1e3d91d84 | ||
|
aeeb34db0f | ||
|
61076b344f | ||
|
9eb9fb494c | ||
|
bbeb729156 | ||
|
b530284519 | ||
|
fcfd0d99cc | ||
|
75f5256b20 | ||
|
eea4eab60b | ||
|
3fb2567ae1 | ||
|
4f833bee46 | ||
|
338e633d48 | ||
|
4e01f35458 | ||
|
e8dfb5eaf4 | ||
|
07d313b1fd | ||
|
0622e4338c | ||
|
097296fcfa | ||
|
861a5a8eb9 | ||
|
7b17b2c103 | ||
|
5def4e8461 | ||
|
8d1ec8f1dc | ||
|
e83054c1ca | ||
|
93111ecdcd | ||
|
17d907335f | ||
|
030dca29f9 | ||
|
2b1aa407d4 | ||
|
5b80273dfb | ||
|
4a3d363bf2 | ||
|
45d12bbf41 | ||
|
96b00064c6 | ||
|
ae5505da7f | ||
|
8b7bf5da6d | ||
|
a5ab7f4767 | ||
|
1ab4b7cec5 | ||
|
8f5d0247bb | ||
|
b2f1782311 | ||
|
1a142548eb | ||
|
c61f18941e | ||
|
bf6bd7af2f | ||
|
05a09cc6cd | ||
|
899d599036 | ||
|
36b24aba5c | ||
|
4a1b1305b5 | ||
|
6b271ad464 | ||
|
a8c90a01ed | ||
|
6c55ed92e2 | ||
|
9273a32f17 | ||
|
4eaf04761e | ||
|
71025ec645 | ||
|
6718730c82 | ||
|
c1d6a4ddda | ||
|
925b28019f | ||
|
4cfc4fc127 | ||
|
e0cb7ea920 | ||
|
bd285e304b | ||
|
9dc973dd38 | ||
|
471be3b551 | ||
|
657c2d01a0 | ||
|
44647a4141 | ||
|
df4fe312fc | ||
|
21169c6caa | ||
|
1e6e3ea9ea | ||
|
21b2dfaf94 | ||
|
f272187ba4 | ||
|
07b9f7cd4b | ||
|
6a4a19a41c | ||
|
91301991c4 | ||
|
e45a668c38 | ||
|
32ce520fc0 | ||
|
7b1dd8170d | ||
|
683cab9796 | ||
|
35997d178d | ||
|
908da0bd24 | ||
|
9ace6afc9e | ||
|
30e52c2b72 | ||
|
2399926cbc | ||
|
102dbc6f9a | ||
|
8817171c86 | ||
|
3b0a59c2c6 | ||
|
85cf3079bd | ||
|
f7af613940 | ||
|
79220ec8a0 | ||
|
0d730f0096 | ||
|
c7f283abd2 | ||
|
1e66bc0c57 | ||
|
3f91edc5b0 | ||
|
d82f5a4573 | ||
|
1f7e8f4d86 | ||
|
a709eb3f4c | ||
|
57cd3e642b | ||
|
1c5a63f8f1 | ||
|
020cb42e38 | ||
|
dca49e8563 | ||
|
1e653e6540 | ||
|
72b2ffefc6 | ||
|
c828fe3bf6 | ||
|
8c8ad47581 | ||
|
fa2425c183 | ||
|
e691425248 | ||
|
6dc1787085 | ||
|
febc9c5e92 | ||
|
772bee2c1f | ||
|
42e6b1fe63 | ||
|
4cfe7b6bfd | ||
|
ebfd35c4bb | ||
|
b9f83c3f4f | ||
|
a86b1a0ea3 | ||
|
37570b3ee0 | ||
|
1e704bcdd6 | ||
|
0566b5ba54 | ||
|
1e975ddce4 | ||
|
5e8422b418 | ||
|
db0c37913c | ||
|
d6ec5c95e2 | ||
|
3ef800fbec | ||
|
d8626b9588 | ||
|
f296cffb06 | ||
|
e15c76e2b6 | ||
|
800f0c3481 | ||
|
2b77651322 | ||
|
c7a3ed35d3 | ||
|
7dd708294f | ||
|
ad3411fb3c | ||
|
0aa9c9d8f7 | ||
|
37e61d1296 | ||
|
cd0b302602 | ||
|
784628952a | ||
|
0554dd13bc | ||
|
f59fbd2c1a | ||
|
f239b4d759 | ||
|
a2761b1f7e | ||
|
44df83c9fb | ||
|
8a9b72ef76 | ||
|
7160d3c137 | ||
|
5c18ff934c | ||
|
057e2bfec2 | ||
|
ceffab182d | ||
|
ea435d91de | ||
|
c567954972 | ||
|
c24b2e01c1 | ||
|
540085c7b1 | ||
|
8fed2d5f82 | ||
|
c1268258c7 | ||
|
4ec78b13ab | ||
|
73d56d6eab | ||
|
f5f9450e24 | ||
|
7308eb2221 | ||
|
ddecdf74b2 | ||
|
dfbeda217f | ||
|
660622805e | ||
|
f148f282e7 | ||
|
b53fb74615 | ||
|
d1c3e89a1d | ||
|
7e67282df9 | ||
|
c97eb2fb0f | ||
|
2f51f79cea | ||
|
919a3e96fc | ||
|
e2e1253270 | ||
|
3ed389aec7 | ||
|
c1b5ceacc3 | ||
|
68e66a6d94 | ||
|
974eeb73b1 | ||
|
50a46b1e08 | ||
|
f2fe68c31a | ||
|
ebad231111 | ||
|
616e9f76dd | ||
|
945a9bf21d | ||
|
86f1210090 | ||
|
ea4b702c5c | ||
|
9c724ec550 | ||
|
21b574ee87 | ||
|
13143a2664 | ||
|
30bf4c3d7a | ||
|
460580b5c5 | ||
|
315f1aaed1 | ||
|
66344d552b | ||
|
fe93b9a92f | ||
|
bc9116237f | ||
|
f1fc6a9063 | ||
|
5a8e250bf5 | ||
|
d3019780a2 | ||
|
a81975cc02 | ||
|
262572dde3 | ||
|
9c232fd838 | ||
|
e3d252708d | ||
|
f1ddcc02f5 | ||
|
9eaf84c991 | ||
|
3b34fb9133 | ||
|
68a8b7e593 | ||
|
4ad07265f0 | ||
|
ecc216ef62 | ||
|
0ddbc48361 | ||
|
76a38dde88 | ||
|
22bb3266f7 | ||
|
085436778a | ||
|
5d9e75dd72 | ||
|
134b71eeaf | ||
|
d3368ef644 | ||
|
74ab26a922 | ||
|
79fbdb4efd | ||
|
01f3d2ef9f | ||
|
6586c9a412 | ||
|
f1032f8033 | ||
|
202e1484f5 | ||
|
21ada8162e | ||
|
ac0b23db62 | ||
|
6bb076f4ea | ||
|
52978f6d68 | ||
|
069c8cbb89 | ||
|
54bc237799 | ||
|
1ce287267e | ||
|
8697710657 | ||
|
013453fdf5 | ||
|
7993e569b8 | ||
|
b3e8c7b6b4 | ||
|
95189818dd | ||
|
50d6c5ca32 | ||
|
4014007a5c | ||
|
0e00115e16 | ||
|
69bcb97889 | ||
|
284ead8f75 | ||
|
0d135182c1 | ||
|
bfa0c2b71e | ||
|
b6b8f34d70 | ||
|
887949ed8a | ||
|
70f32abdf8 | ||
|
5d9839b987 | ||
|
ad2c84950a | ||
|
7e035e823a | ||
|
f272d6614d | ||
|
17b343f889 | ||
|
eb9589b0f7 | ||
|
0e8f82c532 | ||
|
3134313e1e | ||
|
12e5b89056 | ||
|
b083cbd9ec | ||
|
88e608137e | ||
|
f4040c9c21 | ||
|
bfc23ee8b4 | ||
|
6deb39e8a7 | ||
|
fd1928c43d | ||
|
c9222b0760 | ||
|
9a4d99827f | ||
|
fcec9bcdd3 | ||
|
7533179287 | ||
|
ed947d3444 | ||
|
6e3aabf369 | ||
|
b5a7e58e66 | ||
|
6b5ee9aba0 | ||
|
ce38ece1ca | ||
|
23397e7ecc | ||
|
701dfcaa09 | ||
|
7dfd14842b | ||
|
fdfec592a9 | ||
|
9b329496ed | ||
|
27380e57da | ||
|
d07c49b8c3 | ||
|
d4f0dc008f | ||
|
c65b2a8228 | ||
|
0dbfd4c2be | ||
|
0bf34e4ea7 | ||
|
ed27c90394 | ||
|
c50ad167e0 | ||
|
7d1d8ce84d | ||
|
c61522c222 | ||
|
e3255443e0 | ||
|
0aa28b4c54 | ||
|
ae760a4b9b | ||
|
9c00fc2f54 | ||
|
10858c6c8e | ||
|
a43cb420d5 | ||
|
b6782c4493 | ||
|
7c3fcfcd4e | ||
|
56b834fc16 | ||
|
978dc76c54 | ||
|
36b99df477 | ||
|
0b9ada60a4 | ||
|
45cd1f8a3a | ||
|
aaa6a8af21 | ||
|
39d5acee77 | ||
|
410de846cd | ||
|
825832769b | ||
|
2b7eb3ebb0 | ||
|
271e6be96e | ||
|
2876cfd8e9 | ||
|
23cfcb8b1a | ||
|
16ed0f8a6a | ||
|
3955956bc4 | ||
|
bd32f2f120 | ||
|
de24089fa3 | ||
|
347348d200 | ||
|
99f386ef1a | ||
|
5e0ca0f43d | ||
|
49a6596388 | ||
|
b41f929f6e | ||
|
987d051fee | ||
|
5b465f81f7 | ||
|
d42de8cb12 | ||
|
01d2028dbe | ||
|
b141f8dd79 | ||
|
31f6a7e851 | ||
|
45b924911c | ||
|
6dab85f945 | ||
|
cc7f5ca0a4 | ||
|
0d0a66151b | ||
|
b3db62263e | ||
|
e0ee1c00b9 | ||
|
b387273573 | ||
|
90c6c2093b | ||
|
b008531c07 | ||
|
efab11bff5 | ||
|
8914cca32c | ||
|
62fdd3e99c | ||
|
fe488b5e8b | ||
|
45a61a67ea | ||
|
5032bcd77b | ||
|
7a7fbb99fa | ||
|
7e51aad0c1 | ||
|
7a81560974 | ||
|
5f99e0cac4 | ||
|
ec1300c6d6 | ||
|
68ec709c51 | ||
|
a63bee2923 | ||
|
44db833c00 | ||
|
e927dbb6a8 | ||
|
52eab1e8e8 | ||
|
d9964f5a72 | ||
|
404c269e8d | ||
|
eaeb535208 | ||
|
a991882484 | ||
|
c811800afb | ||
|
ff3bd54fcd | ||
|
1676bc71d3 | ||
|
3b07c10f86 | ||
|
1a08ed0c10 | ||
|
2723c44b08 | ||
|
37360c4277 | ||
|
4f86d44f35 | ||
|
149daa3435 | ||
|
b3dbb7f334 | ||
|
53106d8b7b | ||
|
b81ff76d98 | ||
|
9147472b84 | ||
|
36678a6580 | ||
|
76c277ac96 | ||
|
33b2f04da6 | ||
|
c0d0730cfe | ||
|
bf19b1c8ed | ||
|
bbff23985b | ||
|
4090869b48 | ||
|
4aa964d1e1 | ||
|
0ab9ebbcc6 | ||
|
35a3ab8050 | ||
|
4d754061ea | ||
|
6ecf3f5841 | ||
|
8da4ad579c | ||
|
4d8a61baba | ||
|
83dd90e61c | ||
|
3e4a6cc3b2 | ||
|
e72be7af6c | ||
|
61f3e77f58 | ||
|
439ba8c7e5 | ||
|
6ebdb6820a | ||
|
52e90c6039 | ||
|
94c3ad5862 | ||
|
8556ad3875 | ||
|
c670047713 | ||
|
5d48b6a7c4 | ||
|
82a3f431fa | ||
|
ea6698b6e4 | ||
|
a7392e92c9 | ||
|
e6ace2615f | ||
|
88da9895f9 | ||
|
bd6c2409c3 | ||
|
91d85deccb | ||
|
88a3ea6f5d | ||
|
c140fa885b | ||
|
7b1dcad3db | ||
|
1fad7dbfc6 | ||
|
a2b70eee3a | ||
|
a4f4f6822d | ||
|
85372e5e5c | ||
|
2bd284a147 | ||
|
378b16b3e4 | ||
|
649a94844a | ||
|
0a98e86de1 | ||
|
5c5a8bf64d | ||
|
3b84adf645 | ||
|
0468b2377d | ||
|
b04c161a94 | ||
|
5a23ffed69 | ||
|
15af963aaa | ||
|
e7f0d4950a | ||
|
f6b5f3d342 | ||
|
38419aa385 | ||
|
e5dc36cd6d | ||
|
6e33e0efea | ||
|
9db0ae544c | ||
|
c88ebe9715 | ||
|
26e896b647 | ||
|
239d201d9f | ||
|
8516060bc7 | ||
|
8dde4a8cd0 | ||
|
61cb948f3d | ||
|
9a04824c02 | ||
|
7dc0512007 | ||
|
f18a5421e5 | ||
|
a397974325 | ||
|
68ff77ec99 | ||
|
5bc848ed5f | ||
|
9dbe9e663f | ||
|
66ac4730bd | ||
|
f362863326 | ||
|
094c0382a6 | ||
|
70d074f386 | ||
|
33a62752e2 | ||
|
3d1a5a88f2 | ||
|
c5a5fd8eb6 | ||
|
08463222e5 | ||
|
35b3739847 | ||
|
5773f00cd3 | ||
|
b139a4a7ff | ||
|
51e24bec27 | ||
|
7483d6b973 | ||
|
07096f0dec | ||
|
63512d2596 | ||
|
2dd7eb49bb | ||
|
c52c3309fe | ||
|
69e6e4ea25 | ||
|
367e4ce8fb | ||
|
a270c94e03 | ||
|
1a44c10c0d | ||
|
3e6c5147cd | ||
|
e1ae9136e1 | ||
|
df53ee610e | ||
|
d9a03907e0 | ||
|
fda8f34dab | ||
|
15f3de83e4 | ||
|
e11dc1dbb2 | ||
|
dd9241a015 | ||
|
eef7565106 | ||
|
eeb0b921dc | ||
|
a6a3f8042f | ||
|
8db85c545b | ||
|
e39ee1373b | ||
|
6fbea58edc | ||
|
61be1c37ac | ||
|
28126f612a | ||
|
552b9b52bc | ||
|
8976b939b6 | ||
|
667bf4ab13 | ||
|
8e03075ebc | ||
|
55be0860b9 | ||
|
0728ab6b49 | ||
|
1bf1b8898f | ||
|
d42b5fbb45 | ||
|
8593d76984 | ||
|
a3f184979e | ||
|
450ef4dc90 | ||
|
4e60002bac | ||
|
7941a35111 | ||
|
519c85a3ac | ||
|
0134c0ff92 | ||
|
4478366855 | ||
|
54e270bf17 | ||
|
cf4076d613 | ||
|
ef78cc3447 | ||
|
1b9fbb4120 | ||
|
e88b7d2051 | ||
|
aa0e374bca | ||
|
271cca3cd5 | ||
|
7c9247dc2c | ||
|
dc6e3f5a7f | ||
|
e3f74aac09 | ||
|
b4e9ff5086 | ||
|
c4a56c1a2a | ||
|
3a4df634cf | ||
|
c2b6b6a555 | ||
|
360bdf4f23 | ||
|
1a9f955a37 | ||
|
94ef89a95f | ||
|
0c1e67291a | ||
|
b800574a26 | ||
|
6e53e00757 | ||
|
a4777e442e | ||
|
415a2541fe | ||
|
ea30054508 | ||
|
d59b485167 | ||
|
a56dac54fe | ||
|
d261779b6d | ||
|
82172727e9 | ||
|
add7c1841d | ||
|
df8bc8a83f | ||
|
28467cdf24 | ||
|
fc5c60f85b | ||
|
f5a5929972 | ||
|
cf17593b57 | ||
|
e7d7b046db | ||
|
a7608b878d | ||
|
5baf4d6b86 | ||
|
e83bf1c806 | ||
|
865cd632d6 | ||
|
89d1310eac | ||
|
8977e3ea2c | ||
|
4c634abc1e | ||
|
26a1b623e7 | ||
|
5e47c5e5f7 | ||
|
9c310c01c8 | ||
|
8a48a1cce4 | ||
|
910c3f4b3b | ||
|
37d21eea56 | ||
|
cd2c07f586 | ||
|
4041b9b393 | ||
|
ad82e72969 | ||
|
1865d2e6a8 | ||
|
8e2cf0f28d | ||
|
eaa9706244 | ||
|
d887e9f207 | ||
|
826ae13777 | ||
|
6ec1a3fb64 | ||
|
f2f46368d9 | ||
|
e64a52f5c3 | ||
|
314e739af5 | ||
|
9f4cb15eba | ||
|
8142f312b6 | ||
|
1f3c358f0a | ||
|
5ffee47224 | ||
|
53297b820c | ||
|
54558e9799 | ||
|
74e9954b9c | ||
|
49318dca38 | ||
|
dcdb59ddd5 | ||
|
20a0fbbcfb | ||
|
9238d58900 | ||
|
9f37eaa006 | ||
|
8f673cc7f3 | ||
|
ea372b1437 | ||
|
817b9ad771 | ||
|
2e1e239be5 | ||
|
3be4830711 | ||
|
326ff1fe92 | ||
|
833a1c8ecd | ||
|
38e0dd87e0 | ||
|
8de5672832 |
40
.fleet/run.json
Normal 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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"nodejs.editor.formatOnSave.prettier.mode": "Enabled",
|
||||
"nodejs.editor.formatOnSave.eslint.mode": "Enabled"
|
||||
}
|
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @movie-web/core
|
1
.github/CODE_OF_CONDUCT.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Please visit the [main document at primary repository](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md).
|
1
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Please visit the [main document at primary repository](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md).
|
15
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
The movie-web maintainers only support the latest version of movie-web published at https://movie-web.app.
|
||||
This published version is equivalent to the master branch.
|
||||
|
||||
Support is not provided for any forks or mirrors of movie-web.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
There are two ways you can contact the movie-web maintainers to report a vulnerability:
|
||||
|
||||
- Email [security@movie-web.app](mailto:security@movie-web.app)
|
||||
- Report the vulnerability in the [movie-web Discord server](https://discord.movie-web.app)
|
6
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
This pull request resolves #XXX
|
||||
|
||||
- [ ] I have read and agreed to the [code of conduct](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md).
|
||||
- [ ] I have read and complied with the [contributing guidelines](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md).
|
||||
- [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/movie-web/movie-web/projects).
|
||||
- [ ] I have tested all of my changes.
|
112
.github/workflows/build-mobile-comment.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
name: "build mobile app via /build"
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.issue.pull_request && contains(github.event.comment.body, '/build')
|
||||
steps:
|
||||
- uses: xt0rted/pull-request-comment-branch@v2
|
||||
id: comment-branch
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Build Android app
|
||||
run: cd apps/expo && pnpm apk
|
||||
|
||||
- name: Upload movie-web.apk as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: apk
|
||||
path: ./apps/expo/android/app/build/movie-web.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-14
|
||||
if: github.event.issue.pull_request && contains(github.event.comment.body, '/build')
|
||||
steps:
|
||||
- uses: xt0rted/pull-request-comment-branch@v2
|
||||
id: comment-branch
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Cache Pods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/expo/ios
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build iOS app
|
||||
run: cd apps/expo && pnpm ipa
|
||||
|
||||
- name: Upload movie-web.ipa as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ipa
|
||||
path: ./apps/expo/ios/build/movie-web.ipa
|
103
.github/workflows/build-mobile.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
name: build mobile app
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Build Android app
|
||||
run: cd apps/expo && pnpm apk
|
||||
|
||||
- name: Upload movie-web.apk as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: apk
|
||||
path: ./apps/expo/android/app/build/movie-web.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-14
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Cache Pods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/expo/ios
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build iOS app
|
||||
run: cd apps/expo && pnpm ipa
|
||||
|
||||
- name: Upload movie-web.ipa as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ipa
|
||||
path: ./apps/expo/ios/build/movie-web.ipa
|
53
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: ["master"]
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
|
||||
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
|
||||
env:
|
||||
FORCE_COLOR: 3
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint && pnpm lint:ws
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Format
|
||||
run: pnpm format
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
193
.github/workflows/release-mobile.yml
vendored
Normal file
@@ -0,0 +1,193 @@
|
||||
name: release mobile app
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Automated Version Bump
|
||||
uses: phips28/gh-action-bump-version@v11.0.0
|
||||
with:
|
||||
skip-tag: "true"
|
||||
commit-message: "chore: bump mobile version to {{version}} [skip ci]"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PACKAGEJSON_DIR: "apps/expo"
|
||||
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump-version]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Pull version bump
|
||||
run: git pull --all
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Build Android app
|
||||
run: cd apps/expo && pnpm apk
|
||||
|
||||
- name: Upload movie-web.apk as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: apk
|
||||
path: ./apps/expo/android/app/build/movie-web.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-14
|
||||
needs: [bump-version]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Pull version bump
|
||||
run: git pull --all
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "**/node_modules"
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Cache Pods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/expo/ios
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build iOS app
|
||||
run: cd apps/expo && pnpm ipa
|
||||
|
||||
- name: Upload movie-web.ipa as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ipa
|
||||
path: ./apps/expo/ios/build/movie-web.ipa
|
||||
|
||||
release-app:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-android, build-ios]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Pull version bump
|
||||
run: git pull --all
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Get package version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@v1.3.1
|
||||
with:
|
||||
path: apps/expo
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ steps.package-version.outputs.current-version }}
|
||||
files: |
|
||||
movie-web.apk
|
||||
movie-web.ipa
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
token: ${{ env.GITHUB_TOKEN }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
app-repo:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-ios, release-app]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Pull version bump
|
||||
run: git pull --all
|
||||
|
||||
- name: Download IPA artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ipa
|
||||
|
||||
- name: Update app-repo.json
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' apps/expo/package.json)
|
||||
DATE=$(date -u +"%Y-%m-%d")
|
||||
IPA_SIZE=$(ls -l movie-web.ipa | awk '{print $5}')
|
||||
NEW_ENTRY=$(jq -n --arg version "$VERSION" --arg date "$DATE" --arg size "$IPA_SIZE" --arg downloadURL "https://github.com/movie-web/native-app/releases/download/v$VERSION/movie-web.ipa" '{version: $version, date: $date, size: ($size | tonumber), downloadURL: $downloadURL}')
|
||||
jq --argjson newEntry "$NEW_ENTRY" '.apps[0].versions |= [$newEntry] + .' apps/expo/app-repo.json > temp.json && mv temp.json apps/expo/app-repo.json
|
||||
|
||||
- uses: EndBug/add-and-commit@v9
|
||||
with:
|
||||
default_author: github_actions
|
||||
message: "chore: update app-repo.json"
|
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
# nitro
|
||||
.nitro/
|
||||
.output/
|
||||
|
||||
# expo
|
||||
.expo/
|
||||
dist/
|
||||
expo-env.d.ts
|
||||
apps/expo/.gitignore
|
||||
ios/
|
||||
android/
|
||||
!modules/*/ios/
|
||||
!modules/*/android/
|
||||
|
||||
# production
|
||||
build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# tamagui
|
||||
.tamagui
|
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"expo.vscode-expo-tools",
|
||||
"esbenp.prettier-vscode",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
60
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run iOS",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"ios",
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/expo",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
"name": "Run Android",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"android",
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/expo",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
"name": "Build IPA",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"ipa",
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/expo",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
"name": "Build APK",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"apk",
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/expo",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"type": "node"
|
||||
},
|
||||
]
|
||||
}
|
31
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"eslint.workingDirectories": [
|
||||
{
|
||||
"mode": "auto"
|
||||
}
|
||||
],
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"eslint.format.enable": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
||||
],
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
// Should import Text from UI components instead
|
||||
"react-native/Libraries/Text/Text.d.ts"
|
||||
],
|
||||
"[github-actions-workflow]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
77
README.md
@@ -1,2 +1,75 @@
|
||||
# native-app
|
||||
The native app version of movie-web
|
||||
# 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
|
||||
|
||||
This repository uses [Turborepo](https://turborepo.org) and contains:
|
||||
|
||||
```text
|
||||
.github
|
||||
└─ workflows
|
||||
└─ CI with pnpm cache setup
|
||||
.vscode
|
||||
└─ Recommended extensions and settings for VSCode users
|
||||
apps
|
||||
└─ expo
|
||||
├─ Expo SDK 50
|
||||
├─ React Native using React 18
|
||||
├─ Navigation using Expo Router
|
||||
└─ Styling with Tamagui
|
||||
packages
|
||||
├─ api
|
||||
| └─ Typesafe API calls to the backend
|
||||
├─ tmdb
|
||||
| └─ Typesafe API calls to The Movie Database
|
||||
└─ provider-utils
|
||||
└─ Typesafe API calls to the video providers
|
||||
tooling
|
||||
├─ color
|
||||
| └─ shared color palette
|
||||
├─ eslint
|
||||
| └─ shared, fine-grained, eslint presets
|
||||
├─ prettier
|
||||
| └─ shared prettier configuration
|
||||
└─ typescript
|
||||
└─ shared tsconfig you can extend from
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
||||
### When it's time to add a new package
|
||||
|
||||
To add a new package, simply run `pnpm turbo gen init` in the monorepo root. This will prompt you for a package name as
|
||||
well as if you want to install any dependencies to the new package (of course you can also do this yourself later).
|
||||
|
||||
The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary
|
||||
configurations for tooling around your package such as formatting, linting and typechecking. When the package is
|
||||
created, you're ready to go build out the package.
|
||||
|
||||
### References
|
||||
|
||||
This app is based on [create-t3-turbo](https://github.com/t3-oss/create-t3-turbo)
|
||||
and [Turborepo](https://turborepo.org).
|
||||
|
4
apps/expo/.expo-shared/assets.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
1
apps/expo/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
tamagui-web.css
|
18
apps/expo/app-repo.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"apps": [
|
||||
{
|
||||
"name": "movie-web",
|
||||
"bundleIdentifier": "dev.movieweb.app",
|
||||
"category": "entertainment",
|
||||
"developerName": "movie-web",
|
||||
"iconURL": "https://github.com/movie-web/native-app/blob/master/apps/expo/assets/images/icon.png?raw=true",
|
||||
"localizedDescription": "This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.",
|
||||
"subtitle": "A small app for watching movies and shows easily",
|
||||
"tintColor": "a87fd1",
|
||||
"versions": [],
|
||||
"appPermissions": {}
|
||||
}
|
||||
],
|
||||
"news": []
|
||||
}
|
100
apps/expo/app.config.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { ExpoConfig } from "expo/config";
|
||||
|
||||
import { version } from "./package.json";
|
||||
import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement";
|
||||
|
||||
const defineConfig = (): ExpoConfig => ({
|
||||
name: "movie-web",
|
||||
slug: "mw-mobile",
|
||||
scheme: "movieweb",
|
||||
version,
|
||||
icon: "./assets/images/icon.png",
|
||||
userInterfaceStyle: "automatic",
|
||||
splash: {
|
||||
image: "./assets/images/splash.png",
|
||||
resizeMode: "contain",
|
||||
backgroundColor: "#000000",
|
||||
},
|
||||
updates: {
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
assetBundlePatterns: ["**/*"],
|
||||
ios: {
|
||||
bundleIdentifier: "dev.movieweb.app",
|
||||
supportsTablet: true,
|
||||
requireFullScreen: true,
|
||||
infoPlist: {
|
||||
CFBundleName: "movie-web",
|
||||
NSPhotoLibraryUsageDescription:
|
||||
"This app saves videos to the photo library.",
|
||||
NSAppTransportSecurity: {
|
||||
NSAllowsArbitraryLoads: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
android: {
|
||||
package: "dev.movieweb.app",
|
||||
permissions: ["WRITE_SETTINGS"],
|
||||
},
|
||||
web: {
|
||||
favicon: "./assets/images/favicon.png",
|
||||
bundler: "metro",
|
||||
},
|
||||
experiments: {
|
||||
tsconfigPaths: true,
|
||||
typedRoutes: true,
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[withRemoveiOSNotificationEntitlement as unknown as string],
|
||||
[
|
||||
"expo-screen-orientation",
|
||||
{
|
||||
initialOrientation: "PORTRAIT_UP",
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
android: {
|
||||
minSdkVersion: 24,
|
||||
packagingOptions: {
|
||||
pickFirst: [
|
||||
"lib/x86/libcrypto.so",
|
||||
"lib/x86_64/libcrypto.so",
|
||||
"lib/armeabi-v7a/libcrypto.so",
|
||||
"lib/arm64-v8a/libcrypto.so",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-alternate-app-icons",
|
||||
[
|
||||
"./assets/images/main.png",
|
||||
"./assets/images/blue.png",
|
||||
"./assets/images/gray.png",
|
||||
"./assets/images/red.png",
|
||||
"./assets/images/teal.png",
|
||||
],
|
||||
],
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
||||
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
|
||||
isAccessMediaLocationEnabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-pod-pinner",
|
||||
{
|
||||
targetName: "movieweb",
|
||||
pods: [{ "OpenSSL-Universal": "1.1.2200" }],
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
export default defineConfig;
|
BIN
apps/expo/assets/fonts/OpenSans-Bold.ttf
Normal file
BIN
apps/expo/assets/fonts/OpenSans-ExtraBold.ttf
Normal file
BIN
apps/expo/assets/fonts/OpenSans-Light.ttf
Normal file
BIN
apps/expo/assets/fonts/OpenSans-Medium.ttf
Normal file
BIN
apps/expo/assets/fonts/OpenSans-Regular.ttf
Normal file
BIN
apps/expo/assets/fonts/OpenSans-SemiBold.ttf
Normal file
BIN
apps/expo/assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
apps/expo/assets/images/blue.png
Normal file
After Width: | Height: | Size: 5.8 KiB |
BIN
apps/expo/assets/images/favicon.png
Normal file
After Width: | Height: | Size: 721 B |
BIN
apps/expo/assets/images/gray.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
apps/expo/assets/images/icon.png
Normal file
After Width: | Height: | Size: 59 KiB |
BIN
apps/expo/assets/images/main.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
apps/expo/assets/images/red.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
apps/expo/assets/images/splash.png
Normal file
After Width: | Height: | Size: 244 KiB |
BIN
apps/expo/assets/images/teal.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
21
apps/expo/babel.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/** @type {import("@babel/core").ConfigFunction} */
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: [
|
||||
"@babel/plugin-transform-class-static-block",
|
||||
"react-native-reanimated/plugin",
|
||||
[
|
||||
"module-resolver",
|
||||
{
|
||||
alias: {
|
||||
crypto: "react-native-quick-crypto",
|
||||
stream: "stream-browserify",
|
||||
buffer: "@craftzdog/react-native-buffer",
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
};
|
31
apps/expo/eas.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 4.1.2"
|
||||
},
|
||||
"build": {
|
||||
"base": {
|
||||
"node": "18.16.1",
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"extends": "base",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"extends": "base"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
7
apps/expo/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import "expo-router/entry";
|
||||
import "react-native-gesture-handler";
|
||||
import "@react-native-anywhere/polyfill-base64";
|
||||
import "text-encoding-polyfill";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import crypto from "react-native-quick-crypto";
|
60
apps/expo/metro.config.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Learn more: https://docs.expo.dev/guides/monorepos/
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { FileStore } = require("metro-cache");
|
||||
const { withTamagui } = require("@tamagui/metro-plugin");
|
||||
|
||||
const path = require("path");
|
||||
|
||||
module.exports = withTurborepoManagedCache(
|
||||
withMonorepoPaths(
|
||||
withTamagui(
|
||||
getDefaultConfig(__dirname, {
|
||||
isCSSEnabled: true,
|
||||
}),
|
||||
{
|
||||
components: ["tamagui"],
|
||||
config: "./tamagui.config.ts",
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Add the monorepo paths to the Metro config.
|
||||
* This allows Metro to resolve modules from the monorepo.
|
||||
*
|
||||
* @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
|
||||
* @param {import('expo/metro-config').MetroConfig} config
|
||||
* @returns {import('expo/metro-config').MetroConfig}
|
||||
*/
|
||||
function withMonorepoPaths(config) {
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
// #1 - Watch all files in the monorepo
|
||||
config.watchFolders = [workspaceRoot];
|
||||
|
||||
// #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
];
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the Metro cache to the `node_modules/.cache/metro` folder.
|
||||
* This repository configured Turborepo to use this cache location as well.
|
||||
* If you have any environment variables, you can configure Turborepo to invalidate it when needed.
|
||||
*
|
||||
* @see https://turbo.build/repo/docs/reference/configuration#env
|
||||
* @param {import('expo/metro-config').MetroConfig} config
|
||||
* @returns {import('expo/metro-config').MetroConfig}
|
||||
*/
|
||||
function withTurborepoManagedCache(config) {
|
||||
config.cacheStores = [
|
||||
new FileStore({ root: path.join(__dirname, "node_modules/.cache/metro") }),
|
||||
];
|
||||
return config;
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["CheckIosCertificateModule"]
|
||||
}
|
||||
}
|
11
apps/expo/modules/check-ios-certificate/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import CheckIosCertificateModule from "./src/CheckIosCertificateModule";
|
||||
|
||||
interface CheckIosCertificateModule {
|
||||
isDevelopmentProvisioningProfile(): boolean;
|
||||
}
|
||||
|
||||
export function isDevelopmentProvisioningProfile(): boolean {
|
||||
return (
|
||||
CheckIosCertificateModule as CheckIosCertificateModule
|
||||
).isDevelopmentProvisioningProfile();
|
||||
}
|
@@ -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
|
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
import { UnavailabilityError } from "expo-modules-core";
|
||||
|
||||
export default {
|
||||
isDevelopmentProvisioningProfile: () => {
|
||||
throw new UnavailabilityError(
|
||||
"CheckIosCertificate",
|
||||
"isDevelopmentProvisioningProfile",
|
||||
);
|
||||
},
|
||||
};
|
@@ -0,0 +1,5 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
|
||||
// It loads the native module object from the JSI or falls back to
|
||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
||||
export default requireNativeModule("CheckIosCertificate");
|
109
apps/expo/package.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"name": "@movie-web/mobile",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .expo .turbo node_modules",
|
||||
"dev": "expo start",
|
||||
"dev:android": "expo start -c --android",
|
||||
"dev:ios": "expo start -c --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"apk": "expo prebuild --platform=android && cd android && ./gradlew assembleRelease && mv app/build/outputs/apk/release/app-release.apk app/build/movie-web.apk",
|
||||
"ipa": "expo prebuild --platform=ios && cd ios && xcodebuild clean archive -workspace movieweb.xcworkspace -scheme movieweb -configuration Release -destination generic/platform=iOS -archivePath build/movieweb.xcarchive CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_ALLOWED=NO | xcbeautify && cd build/movieweb.xcarchive/Products && mv Applications Payload && zip -r movie-web.ipa Payload && mv movie-web.ipa ../..",
|
||||
"ipa:sim": "expo prebuild --platform=ios && cd ios && xcodebuild clean archive -workspace movieweb.xcworkspace -scheme movieweb -configuration Release -destination \"generic/platform=iOS Simulator\" -archivePath build/movieweb.xcarchive CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_ALLOWED=NO | xcbeautify && cd build/movieweb.xcarchive/Products && mv Applications Payload && zip -r movie-web.ipa Payload && mv movie-web.ipa ../..",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-config": "^0.17.3",
|
||||
"@movie-web/api": "*",
|
||||
"@movie-web/colors": "*",
|
||||
"@movie-web/provider-utils": "*",
|
||||
"@movie-web/tmdb": "*",
|
||||
"@octokit/rest": "^20.0.2",
|
||||
"@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0",
|
||||
"@react-navigation/native": "^6.1.9",
|
||||
"@salihgun/react-native-video-processor": "^0.3.1",
|
||||
"@tamagui/animations-moti": "^1.94.0",
|
||||
"@tamagui/babel-plugin": "^1.94.0",
|
||||
"@tamagui/config": "^1.94.0",
|
||||
"@tamagui/metro-plugin": "^1.94.0",
|
||||
"@tamagui/toast": "1.94.0",
|
||||
"@tanstack/react-query": "^5.22.2",
|
||||
"burnt": "^0.12.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"expo": "~50.0.14",
|
||||
"expo-alternate-app-icons": "^0.1.7",
|
||||
"expo-application": "~5.8.3",
|
||||
"expo-av": "~13.10.5",
|
||||
"expo-brightness": "~11.8.0",
|
||||
"expo-build-properties": "~0.11.1",
|
||||
"expo-clipboard": "^5.0.1",
|
||||
"expo-constants": "~15.4.5",
|
||||
"expo-file-system": "~16.0.8",
|
||||
"expo-haptics": "~12.8.1",
|
||||
"expo-linear-gradient": "^12.7.2",
|
||||
"expo-linking": "~6.2.2",
|
||||
"expo-media-library": "~15.9.1",
|
||||
"expo-navigation-bar": "^2.8.1",
|
||||
"expo-network": "~5.8.0",
|
||||
"expo-pod-pinner": "^1.0.1",
|
||||
"expo-router": "~3.4.8",
|
||||
"expo-screen-orientation": "~6.4.1",
|
||||
"expo-splash-screen": "~0.26.4",
|
||||
"expo-status-bar": "~1.11.1",
|
||||
"expo-system-ui": "^2.9.3",
|
||||
"expo-web-browser": "^12.8.2",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"immer": "^10.0.3",
|
||||
"iso-639-1": "^3.1.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.73.6",
|
||||
"react-native-context-menu-view": "^1.14.1",
|
||||
"react-native-gesture-handler": "~2.14.1",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-modal": "^13.0.1",
|
||||
"react-native-quick-base64": "^2.0.8",
|
||||
"react-native-quick-crypto": "^0.6.1",
|
||||
"react-native-reanimated": "~3.6.2",
|
||||
"react-native-safe-area-context": "~4.8.2",
|
||||
"react-native-screens": "~3.29.0",
|
||||
"react-native-svg": "14.1.0",
|
||||
"react-native-web": "^0.19.10",
|
||||
"subsrt-ts": "^2.1.2",
|
||||
"tamagui": "^1.94.0",
|
||||
"text-encoding-polyfill": "^0.6.7",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.9",
|
||||
"@babel/preset-env": "^7.23.9",
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@movie-web/eslint-config": "workspace:^0.2.0",
|
||||
"@movie-web/prettier-config": "workspace:^0.1.0",
|
||||
"@movie-web/tsconfig": "workspace:^0.1.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.20.1",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.1.1",
|
||||
"typescript": "^5.4.3"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@movie-web/eslint-config/base",
|
||||
"@movie-web/eslint-config/react"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"expo-plugins/**"
|
||||
]
|
||||
},
|
||||
"prettier": "@movie-web/prettier-config"
|
||||
}
|
65
apps/expo/src/app/(downloads)/[tmdbId].tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { YStack } from "tamagui";
|
||||
|
||||
import { DownloadItem } from "~/components/DownloadItem";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { useDownloadHistoryStore } from "~/stores/settings";
|
||||
|
||||
export default function Page() {
|
||||
const { tmdbId } = useLocalSearchParams();
|
||||
const allDownloads = useDownloadHistoryStore((state) => state.downloads);
|
||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
|
||||
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
|
||||
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
|
||||
const router = useRouter();
|
||||
|
||||
const download = useMemo(() => {
|
||||
return allDownloads.find((download) => download.media.tmdbId === tmdbId);
|
||||
}, [allDownloads, tmdbId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!download) router.back();
|
||||
}, [download, router]);
|
||||
|
||||
const handlePress = (localPath?: string) => {
|
||||
if (!localPath) return;
|
||||
resetVideo();
|
||||
setIsLocalFile(true);
|
||||
setPlayerStatus(PlayerStatus.READY);
|
||||
setVideoSrc({
|
||||
uri: localPath,
|
||||
});
|
||||
router.push({
|
||||
pathname: "/videoPlayer",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenLayout showHeader={false}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: download?.media.title ?? "Downloads",
|
||||
}}
|
||||
/>
|
||||
<YStack gap="$4">
|
||||
{download?.downloads.map((download) => {
|
||||
return (
|
||||
<DownloadItem
|
||||
key={
|
||||
download.media.type === "show"
|
||||
? download.media.episode.tmdbId
|
||||
: download.media.tmdbId
|
||||
}
|
||||
item={download}
|
||||
onPress={() => handlePress(download.localPath)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
14
apps/expo/src/app/(downloads)/_layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
import { BrandPill } from "~/components/BrandPill";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTransparent: true,
|
||||
headerRight: BrandPill,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
113
apps/expo/src/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Platform } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Tabs } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useTheme, View } from "tamagui";
|
||||
|
||||
import { MovieWebSvg } from "~/components/Icon";
|
||||
import SvgTabBarIcon from "~/components/SvgTabBarIcon";
|
||||
import TabBarIcon from "~/components/TabBarIcon";
|
||||
|
||||
export default function TabLayout() {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Tabs
|
||||
sceneContainerStyle={{
|
||||
backgroundColor: theme.screenBackground.val,
|
||||
}}
|
||||
screenListeners={() => ({
|
||||
tabPress: () => {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
},
|
||||
focus: () => {
|
||||
void ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
},
|
||||
})}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: theme.tabBarIconFocused.val,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.tabBarBackground.val,
|
||||
borderTopColor: "transparent",
|
||||
borderTopRightRadius: 20,
|
||||
borderTopLeftRadius: 20,
|
||||
paddingBottom: Platform.select({ ios: 100 }),
|
||||
height: 80,
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
paddingVertical: 18,
|
||||
height: 82,
|
||||
},
|
||||
tabBarLabelStyle: [
|
||||
{
|
||||
marginTop: 2,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabBarIcon name="home" focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="downloads"
|
||||
options={{
|
||||
title: "Downloads",
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabBarIcon name="download" focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="search"
|
||||
options={{
|
||||
title: "Search",
|
||||
tabBarLabel: "",
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<View
|
||||
top={2}
|
||||
height={56}
|
||||
width={56}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
borderRadius={100}
|
||||
backgroundColor={
|
||||
focused ? theme.tabBarIconFocused : theme.tabBarIcon
|
||||
}
|
||||
>
|
||||
<TabBarIcon name="search" color="#FFF" />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="movie-web"
|
||||
options={{
|
||||
title: "movie-web",
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<SvgTabBarIcon focused={focused}>
|
||||
<MovieWebSvg />
|
||||
</SvgTabBarIcon>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabBarIcon name="cog" focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
165
apps/expo/src/app/(tabs)/downloads.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React from "react";
|
||||
import { Alert, Platform } from "react-native";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { isDevelopmentProvisioningProfile } from "modules/check-ios-certificate";
|
||||
import { ScrollView, useTheme, YStack } from "tamagui";
|
||||
|
||||
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
||||
|
||||
import { DownloadItem, ShowDownloadItem } from "~/components/DownloadItem";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { useDownloadHistoryStore } from "~/stores/settings";
|
||||
|
||||
const exampleMovieMedia: ScrapeMedia = {
|
||||
type: "movie",
|
||||
title: "Avengers: Endgame",
|
||||
releaseYear: 2019,
|
||||
imdbId: "tt4154796",
|
||||
tmdbId: "299534",
|
||||
};
|
||||
|
||||
const getExampleShowMedia = (seasonNumber: number, episodeNumber: number) =>
|
||||
({
|
||||
type: "show",
|
||||
title: "Loki",
|
||||
releaseYear: 2021,
|
||||
imdbId: "tt9140554",
|
||||
tmdbId: "84958",
|
||||
season: {
|
||||
number: seasonNumber,
|
||||
tmdbId: seasonNumber.toString(),
|
||||
},
|
||||
episode: {
|
||||
number: episodeNumber,
|
||||
tmdbId: episodeNumber.toString(),
|
||||
},
|
||||
}) as const;
|
||||
|
||||
const TestDownloadButton = (props: {
|
||||
media: ScrapeMedia;
|
||||
type: "hls" | "mp4";
|
||||
url: string;
|
||||
}) => {
|
||||
const { startDownload } = useDownloadManager();
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={async () => {
|
||||
await startDownload(props.url, props.type, props.media).catch(
|
||||
console.error,
|
||||
);
|
||||
}}
|
||||
>
|
||||
test download
|
||||
{props.type === "hls" ? " (hls)" : "(mp4)"}{" "}
|
||||
{props.media.type === "show" ? "show" : "movie"}
|
||||
</MWButton>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadsScreen: React.FC = () => {
|
||||
const downloads = useDownloadHistoryStore((state) => state.downloads);
|
||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
|
||||
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
|
||||
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
|
||||
const router = useRouter();
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
if (Platform.OS === "ios" && !isDevelopmentProvisioningProfile()) {
|
||||
Alert.alert(
|
||||
"Production Certificate",
|
||||
"Download functionality is not available when the application is signed with a distribution certificate.",
|
||||
[
|
||||
{
|
||||
text: "OK",
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}, [router]),
|
||||
);
|
||||
|
||||
const handlePress = (localPath?: string) => {
|
||||
if (!localPath) return;
|
||||
resetVideo();
|
||||
setIsLocalFile(true);
|
||||
setPlayerStatus(PlayerStatus.READY);
|
||||
setVideoSrc({
|
||||
uri: localPath,
|
||||
});
|
||||
router.push({
|
||||
pathname: "/videoPlayer",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
<YStack gap={2} style={{ padding: 10 }}>
|
||||
<TestDownloadButton
|
||||
media={exampleMovieMedia}
|
||||
type="mp4"
|
||||
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
/>
|
||||
<TestDownloadButton
|
||||
media={getExampleShowMedia(1, 1)}
|
||||
type="mp4"
|
||||
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
/>
|
||||
<TestDownloadButton
|
||||
media={getExampleShowMedia(1, 2)}
|
||||
type="mp4"
|
||||
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
/>
|
||||
<TestDownloadButton
|
||||
media={getExampleShowMedia(1, 1)}
|
||||
type="hls"
|
||||
url="http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8"
|
||||
/>
|
||||
</YStack>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
gap: "$4",
|
||||
}}
|
||||
>
|
||||
{downloads.map((download) => {
|
||||
if (download.downloads.length === 0) return null;
|
||||
if (download.media.type === "movie") {
|
||||
return (
|
||||
<DownloadItem
|
||||
key={download.media.tmdbId}
|
||||
item={download.downloads[0]!}
|
||||
onPress={() => handlePress(download.downloads[0]!.localPath)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ShowDownloadItem
|
||||
key={download.media.tmdbId}
|
||||
download={download}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ScrollView>
|
||||
</ScreenLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadsScreen;
|
23
apps/expo/src/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { View } from "tamagui";
|
||||
|
||||
import { ItemListSection } from "~/components/item/ItemListSection";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { bookmarks } = useBookmarkStore();
|
||||
const { watchHistory } = useWatchHistoryStore();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }} flex={1}>
|
||||
<ScreenLayout>
|
||||
<ItemListSection title="Bookmarks" items={bookmarks} />
|
||||
<ItemListSection
|
||||
title="Continue Watching"
|
||||
items={watchHistory.map((x) => x.item)}
|
||||
/>
|
||||
</ScreenLayout>
|
||||
</View>
|
||||
);
|
||||
}
|
10
apps/expo/src/app/(tabs)/movie-web.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AccountInformation } from "~/components/account/AccountInformation";
|
||||
import { AccountGetStarted } from "~/components/account/GetStarted";
|
||||
import { useAuthStore } from "~/stores/settings";
|
||||
|
||||
export default function MovieWebScreen() {
|
||||
const account = useAuthStore((state) => state.account);
|
||||
|
||||
if (account) return <AccountInformation />;
|
||||
return <AccountGetStarted />;
|
||||
}
|
159
apps/expo/src/app/(tabs)/search.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Keyboard } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { View, ZStack } from "tamagui";
|
||||
|
||||
import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
|
||||
|
||||
import type { ItemData } from "~/components/item/item";
|
||||
import Item from "~/components/item/item";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { SearchBar } from "~/components/ui/Searchbar";
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [query, setQuery] = useState("");
|
||||
const translateY = useSharedValue(0);
|
||||
const fadeAnim = useSharedValue(1);
|
||||
const searchResultsOpacity = useSharedValue(0);
|
||||
const searchResultsScale = useSharedValue(0.95);
|
||||
const [searchResultsLoaded, setSearchResultsLoaded] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["searchResults", query],
|
||||
queryFn: () => fetchSearchResults(query),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data.length > 0 && query) {
|
||||
searchResultsOpacity.value = withTiming(1, { duration: 500 });
|
||||
searchResultsScale.value = withTiming(1, { duration: 500 });
|
||||
setSearchResultsLoaded(true);
|
||||
} else if (!query) {
|
||||
searchResultsOpacity.value = withTiming(0, { duration: 500 });
|
||||
searchResultsScale.value = withTiming(0.95, { duration: 500 });
|
||||
setSearchResultsLoaded(false);
|
||||
}
|
||||
}, [data, query, searchResultsOpacity, searchResultsScale]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardWillShowListener = Keyboard.addListener(
|
||||
"keyboardWillShow",
|
||||
(e) => {
|
||||
translateY.value = withTiming(
|
||||
-(e.endCoordinates.height - 100), // determines the height of the Searchbar above keyboard, use Platform.select to adjust value if needed
|
||||
{
|
||||
duration: e.duration ?? 250, // duration always returns 0 on Android, adjust value if needed
|
||||
easing: Easing.out(Easing.ease),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const keyboardWillHideListener = Keyboard.addListener(
|
||||
"keyboardWillHide",
|
||||
() => {
|
||||
translateY.value = withTiming(0, {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.ease),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
keyboardWillShowListener.remove();
|
||||
keyboardWillHideListener.remove();
|
||||
};
|
||||
}, [translateY]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: fadeAnim.value,
|
||||
};
|
||||
});
|
||||
|
||||
const searchResultsStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: searchResultsOpacity.value,
|
||||
transform: [{ scale: searchResultsScale.value }],
|
||||
};
|
||||
});
|
||||
|
||||
const handleScrollBegin = () => {
|
||||
fadeAnim.value = withTiming(0, {
|
||||
duration: 100,
|
||||
});
|
||||
};
|
||||
|
||||
const handleScrollEnd = () => {
|
||||
fadeAnim.value = withTiming(1, {
|
||||
duration: 100,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ZStack flex={1}>
|
||||
<ScreenLayout
|
||||
onScrollBeginDrag={handleScrollBegin}
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
scrollEnabled={searchResultsLoaded ? true : false}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
>
|
||||
<View>
|
||||
<Animated.View style={[searchResultsStyle, { flex: 1 }]}>
|
||||
<View flexDirection="row" flexWrap="wrap">
|
||||
{data?.map((item, index) => (
|
||||
<View
|
||||
key={index}
|
||||
paddingHorizontal={12}
|
||||
paddingBottom={12}
|
||||
width="50%"
|
||||
>
|
||||
<Item data={item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</ScreenLayout>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: 5,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<SearchBar onSearchChange={setQuery} />
|
||||
</Animated.View>
|
||||
</ZStack>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchSearchResults(query: string): Promise<ItemData[]> {
|
||||
const results = await searchTitle(query);
|
||||
|
||||
return results.map((result) => ({
|
||||
id: result.id.toString(),
|
||||
title: result.media_type === "tv" ? result.name : result.title,
|
||||
posterUrl: getMediaPoster(result.poster_path),
|
||||
release_date: new Date(
|
||||
result.media_type === "tv" ? result.first_air_date : result.release_date,
|
||||
),
|
||||
year: new Date(
|
||||
result.media_type === "tv" ? result.first_air_date : result.release_date,
|
||||
).getFullYear(),
|
||||
type: result.media_type,
|
||||
}));
|
||||
}
|
525
apps/expo/src/app/(tabs)/settings.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
import type { SelectProps } from "tamagui";
|
||||
import React, { useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import * as Application from "expo-application";
|
||||
import * as Brightness from "expo-brightness";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import {
|
||||
FontAwesome,
|
||||
MaterialCommunityIcons,
|
||||
MaterialIcons,
|
||||
} from "@expo/vector-icons";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Adapt,
|
||||
ScrollView,
|
||||
Select,
|
||||
Sheet,
|
||||
Spinner,
|
||||
Text,
|
||||
useTheme,
|
||||
View,
|
||||
XStack,
|
||||
YStack,
|
||||
} from "tamagui";
|
||||
|
||||
import type { ThemeStoreOption } from "~/stores/theme";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWSelect } from "~/components/ui/Select";
|
||||
import { MWSeparator } from "~/components/ui/Separator";
|
||||
import { MWSwitch } from "~/components/ui/Switch";
|
||||
import { useToast } from "~/hooks/useToast";
|
||||
import { checkForUpdate } from "~/lib/update";
|
||||
import {
|
||||
useNetworkSettingsStore,
|
||||
usePlayerSettingsStore,
|
||||
} from "~/stores/settings";
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
const themeOptions: ThemeStoreOption[] = [
|
||||
"main",
|
||||
"blue",
|
||||
"gray",
|
||||
"red",
|
||||
"teal",
|
||||
];
|
||||
|
||||
const defaultQualityOptions = ["Highest", "Lowest"];
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const theme = useTheme();
|
||||
const { gestureControls, setGestureControls, autoPlay, setAutoPlay } =
|
||||
usePlayerSettingsStore();
|
||||
const { allowMobileData, setAllowMobileData } = useNetworkSettingsStore();
|
||||
const [showUpdateSheet, setShowUpdateSheet] = useState(false);
|
||||
const [updateMarkdownContent, setUpdateMarkdownContent] = useState("");
|
||||
const [downloadUrl, setDownloadUrl] = useState("");
|
||||
const { showToast } = useToast();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ["checkForUpdate"],
|
||||
mutationFn: checkForUpdate,
|
||||
onSuccess: (res) => {
|
||||
if (res) {
|
||||
setUpdateMarkdownContent(res.data.body!);
|
||||
setDownloadUrl(
|
||||
res.data.assets.find(
|
||||
(asset) =>
|
||||
asset.name ===
|
||||
`movie-web.${Platform.select({ ios: "ipa", android: "apk" })}`,
|
||||
)?.browser_download_url ?? "",
|
||||
);
|
||||
setShowUpdateSheet(true);
|
||||
} else {
|
||||
showToast("No updates available");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleGestureControlsToggle = async (isEnabled: boolean) => {
|
||||
if (isEnabled) {
|
||||
const { status } = await Brightness.requestPermissionsAsync();
|
||||
if (status === Brightness.PermissionStatus.GRANTED) {
|
||||
setGestureControls(isEnabled);
|
||||
}
|
||||
} else {
|
||||
setGestureControls(isEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCacheDirectory = async () => {
|
||||
const cacheDirectory = `${FileSystem.cacheDirectory}movie-web`;
|
||||
if (!cacheDirectory) return;
|
||||
|
||||
try {
|
||||
await FileSystem.deleteAsync(cacheDirectory, { idempotent: true });
|
||||
showToast("Cache cleared", {
|
||||
burntOptions: { preset: "done" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error clearing cache directory:", error);
|
||||
showToast("Error clearing cache", {
|
||||
burntOptions: { preset: "error" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
<View>
|
||||
<YStack gap="$8">
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Appearance
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<Text fontWeight="$semibold" flexGrow={1}>
|
||||
Theme
|
||||
</Text>
|
||||
<ThemeSelector />
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Player
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<Text fontWeight="$semibold" flexGrow={1}>
|
||||
Gesture controls
|
||||
</Text>
|
||||
<MWSwitch
|
||||
checked={gestureControls}
|
||||
onCheckedChange={handleGestureControlsToggle}
|
||||
>
|
||||
<MWSwitch.Thumb />
|
||||
</MWSwitch>
|
||||
</XStack>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<Text fontWeight="$semibold" flexGrow={1}>
|
||||
Autoplay
|
||||
</Text>
|
||||
<MWSwitch checked={autoPlay} onCheckedChange={setAutoPlay}>
|
||||
<MWSwitch.Thumb />
|
||||
</MWSwitch>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Network
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<Text fontWeight="$semibold" flexGrow={1}>
|
||||
Default quality (Wi-Fi)
|
||||
</Text>
|
||||
<DefaultQualitySelector qualityType="wifi" />
|
||||
</XStack>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<Text fontWeight="$semibold" flexGrow={1}>
|
||||
Default quality (Data)
|
||||
</Text>
|
||||
<DefaultQualitySelector qualityType="data" />
|
||||
</XStack>
|
||||
<XStack gap="$3" alignItems="center">
|
||||
<Text fontWeight="$semibold" flexGrow={1}>
|
||||
Allow downloads on mobile data
|
||||
</Text>
|
||||
<MWSwitch
|
||||
checked={allowMobileData}
|
||||
onCheckedChange={setAllowMobileData}
|
||||
>
|
||||
<MWSwitch.Thumb />
|
||||
</MWSwitch>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
App
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<Text fontWeight="$semibold" flexGrow={1}>
|
||||
Version {Application.nativeApplicationVersion}
|
||||
</Text>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name={Platform.select({
|
||||
ios: "apple",
|
||||
android: "android",
|
||||
})}
|
||||
size={24}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
iconAfter={
|
||||
<>{mutation.isPending && <Spinner color="$purple200" />}</>
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
onPress={() => mutation.mutate()}
|
||||
>
|
||||
Update
|
||||
</MWButton>
|
||||
</XStack>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<Text fontWeight="$semibold" flexGrow={1}>
|
||||
Storage
|
||||
</Text>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="broom"
|
||||
size={24}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => clearCacheDirectory()}
|
||||
>
|
||||
Clear Cache
|
||||
</MWButton>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</View>
|
||||
<UpdateSheet
|
||||
markdownContent={updateMarkdownContent}
|
||||
open={showUpdateSheet}
|
||||
setShowUpdateSheet={setShowUpdateSheet}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateSheet({
|
||||
markdownContent,
|
||||
open,
|
||||
setShowUpdateSheet,
|
||||
downloadUrl,
|
||||
}: {
|
||||
markdownContent: string;
|
||||
open: boolean;
|
||||
setShowUpdateSheet: (value: boolean) => void;
|
||||
downloadUrl: string;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
modal
|
||||
open={open}
|
||||
onOpenChange={setShowUpdateSheet}
|
||||
dismissOnSnapToBottom
|
||||
dismissOnOverlayPress
|
||||
animationConfig={{
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
mass: 1.2,
|
||||
stiffness: 250,
|
||||
}}
|
||||
snapPoints={[35]}
|
||||
>
|
||||
<Sheet.Handle backgroundColor="$sheetHandle" />
|
||||
<Sheet.Frame
|
||||
backgroundColor="$sheetBackground"
|
||||
padding="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<ScrollView>
|
||||
<Markdown
|
||||
style={{
|
||||
text: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdownContent}
|
||||
</Markdown>
|
||||
</ScrollView>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name={Platform.select({ ios: "apple", android: "android" })}
|
||||
size={24}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => WebBrowser.openBrowserAsync(downloadUrl)}
|
||||
>
|
||||
Download
|
||||
</MWButton>
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeSelector(props: SelectProps) {
|
||||
const theme = useTheme();
|
||||
const themeStore = useThemeStore((s) => s.theme);
|
||||
const setTheme = useThemeStore((s) => s.setTheme);
|
||||
|
||||
return (
|
||||
<MWSelect
|
||||
value={themeStore}
|
||||
onValueChange={setTheme}
|
||||
disablePreventBodyScroll
|
||||
{...props}
|
||||
>
|
||||
<MWSelect.Trigger
|
||||
maxWidth="$12"
|
||||
iconAfter={
|
||||
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
|
||||
}
|
||||
>
|
||||
<Select.Value fontWeight="$semibold" textTransform="capitalize" />
|
||||
</MWSelect.Trigger>
|
||||
|
||||
<Adapt platform="native">
|
||||
<Sheet
|
||||
modal
|
||||
dismissOnSnapToBottom
|
||||
dismissOnOverlayPress
|
||||
animationConfig={{
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
mass: 1.2,
|
||||
stiffness: 250,
|
||||
}}
|
||||
snapPoints={[35]}
|
||||
>
|
||||
<Sheet.Handle backgroundColor="$sheetHandle" />
|
||||
<Sheet.Frame
|
||||
backgroundColor="$sheetBackground"
|
||||
padding="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Adapt.Contents />
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
</Sheet>
|
||||
</Adapt>
|
||||
|
||||
<Select.Content>
|
||||
<Select.Viewport
|
||||
animation="static"
|
||||
animateOnly={["transform", "opacity"]}
|
||||
enterStyle={{ o: 0, y: -10 }}
|
||||
exitStyle={{ o: 0, y: 10 }}
|
||||
>
|
||||
{themeOptions.map((item, i) => (
|
||||
<Select.Item
|
||||
index={i}
|
||||
key={item}
|
||||
value={item}
|
||||
backgroundColor="$sheetItemBackground"
|
||||
borderTopRightRadius={i === 0 ? "$8" : 0}
|
||||
borderTopLeftRadius={i === 0 ? "$8" : 0}
|
||||
borderBottomRightRadius={i === themeOptions.length - 1 ? "$8" : 0}
|
||||
borderBottomLeftRadius={i === themeOptions.length - 1 ? "$8" : 0}
|
||||
>
|
||||
<Select.ItemText
|
||||
textTransform="capitalize"
|
||||
fontWeight="$semibold"
|
||||
>
|
||||
{item}
|
||||
</Select.ItemText>
|
||||
<Select.ItemIndicator ml="auto">
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.sheetItemSelected.val}
|
||||
/>
|
||||
</Select.ItemIndicator>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</MWSelect>
|
||||
);
|
||||
}
|
||||
|
||||
interface DefaultQualitySelectorProps extends SelectProps {
|
||||
qualityType: "wifi" | "data";
|
||||
}
|
||||
|
||||
export function DefaultQualitySelector(props: DefaultQualitySelectorProps) {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
wifiDefaultQuality,
|
||||
mobileDataDefaultQuality,
|
||||
setWifiDefaultQuality,
|
||||
setMobileDataDefaultQuality,
|
||||
} = useNetworkSettingsStore();
|
||||
|
||||
return (
|
||||
<MWSelect
|
||||
value={
|
||||
props.qualityType === "wifi"
|
||||
? wifiDefaultQuality
|
||||
: mobileDataDefaultQuality
|
||||
}
|
||||
onValueChange={
|
||||
props.qualityType === "wifi"
|
||||
? setWifiDefaultQuality
|
||||
: setMobileDataDefaultQuality
|
||||
}
|
||||
disablePreventBodyScroll
|
||||
{...props}
|
||||
>
|
||||
<MWSelect.Trigger
|
||||
maxWidth="$10"
|
||||
iconAfter={
|
||||
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
|
||||
}
|
||||
>
|
||||
<Select.Value fontWeight="$semibold" textTransform="capitalize" />
|
||||
</MWSelect.Trigger>
|
||||
|
||||
<Adapt platform="native">
|
||||
<Sheet
|
||||
modal
|
||||
dismissOnSnapToBottom
|
||||
dismissOnOverlayPress
|
||||
animationConfig={{
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
mass: 1.2,
|
||||
stiffness: 250,
|
||||
}}
|
||||
snapPoints={[35]}
|
||||
>
|
||||
<Sheet.Handle backgroundColor="$sheetHandle" />
|
||||
<Sheet.Frame
|
||||
backgroundColor="$sheetBackground"
|
||||
padding="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Adapt.Contents />
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
</Sheet>
|
||||
</Adapt>
|
||||
|
||||
<Select.Content>
|
||||
<Select.Viewport
|
||||
animation="static"
|
||||
animateOnly={["transform", "opacity"]}
|
||||
enterStyle={{ o: 0, y: -10 }}
|
||||
exitStyle={{ o: 0, y: 10 }}
|
||||
>
|
||||
{defaultQualityOptions.map((item, i) => (
|
||||
<Select.Item
|
||||
index={i}
|
||||
key={item}
|
||||
value={item}
|
||||
backgroundColor="$sheetItemBackground"
|
||||
borderTopRightRadius={i === 0 ? "$8" : 0}
|
||||
borderTopLeftRadius={i === 0 ? "$8" : 0}
|
||||
borderBottomRightRadius={
|
||||
i === defaultQualityOptions.length - 1 ? "$8" : 0
|
||||
}
|
||||
borderBottomLeftRadius={
|
||||
i === defaultQualityOptions.length - 1 ? "$8" : 0
|
||||
}
|
||||
>
|
||||
<Select.ItemText
|
||||
textTransform="capitalize"
|
||||
fontWeight="$semibold"
|
||||
>
|
||||
{item}
|
||||
</Select.ItemText>
|
||||
<Select.ItemIndicator ml="auto">
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.sheetItemSelected.val}
|
||||
/>
|
||||
</Select.ItemIndicator>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</MWSelect>
|
||||
);
|
||||
}
|
46
apps/expo/src/app/+html.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ScrollViewStyleReset } from "expo-router/html";
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
25
apps/expo/src/app/[...missing].tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { Text, View } from "tamagui";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
if (Linking.useURL()) return null;
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<View flex={1} alignItems="center" justifyContent="center" padding={5}>
|
||||
<Text fontWeight="bold">This screen doesn't exist.</Text>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
paddingVertical: 16,
|
||||
}}
|
||||
>
|
||||
<Text color="skyblue">Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
118
apps/expo/src/app/_layout.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { useEffect } from "react";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { useFonts } from "expo-font";
|
||||
import { SplashScreen, Stack } from "expo-router";
|
||||
import FontAwesome from "@expo/vector-icons/FontAwesome";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { ToastProvider, ToastViewport } from "@tamagui/toast";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TamaguiProvider, Theme, useTheme } from "tamagui";
|
||||
import tamaguiConfig from "tamagui.config";
|
||||
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
// @ts-expect-error - Without named import it causes an infinite loop
|
||||
import _styles from "../../tamagui-web.css";
|
||||
|
||||
export {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
ErrorBoundary,
|
||||
} from "expo-router";
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: "(tabs)",
|
||||
};
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync().catch(() => {
|
||||
/* reloading the app might trigger this, so it's safe to ignore */
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
OpenSansRegular: require("../../assets/fonts/OpenSans-Regular.ttf"),
|
||||
OpenSansLight: require("../../assets/fonts/OpenSans-Light.ttf"),
|
||||
OpenSansMedium: require("../../assets/fonts/OpenSans-Medium.ttf"),
|
||||
OpenSansBold: require("../../assets/fonts/OpenSans-Bold.ttf"),
|
||||
OpenSansSemiBold: require("../../assets/fonts/OpenSans-SemiBold.ttf"),
|
||||
OpenSansExtra: require("../../assets/fonts/OpenSans-ExtraBold.ttf"),
|
||||
...FontAwesome.font,
|
||||
});
|
||||
|
||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||
useEffect(() => {
|
||||
if (error) throw error;
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync().catch(() => {
|
||||
/* reloading the app might trigger this, so it's safe to ignore */
|
||||
});
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<RootLayoutNav />
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenStacks() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
autoHideHomeIndicator: true,
|
||||
gestureEnabled: true,
|
||||
animation: "default",
|
||||
animationTypeForReplace: "push",
|
||||
presentation: "card",
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.screenBackground.val,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
gestureEnabled: true,
|
||||
animation: "default",
|
||||
animationTypeForReplace: "push",
|
||||
presentation: "card",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const themeStore = useThemeStore((s) => s.theme);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TamaguiProvider config={tamaguiConfig} defaultTheme="main">
|
||||
<ToastProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Theme name={themeStore}>
|
||||
<ScreenStacks />
|
||||
</Theme>
|
||||
</ThemeProvider>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
</TamaguiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
14
apps/expo/src/app/sync/_layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
import { BrandPill } from "~/components/BrandPill";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTransparent: true,
|
||||
headerRight: BrandPill,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
124
apps/expo/src/app/sync/login.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import { Link, Stack, useRouter } from "expo-router";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { H4, Label, Paragraph, Text, YStack } from "tamagui";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
import { MWInput } from "~/components/ui/Input";
|
||||
import { useAuth } from "~/hooks/useAuth";
|
||||
import { useAuthStore } from "~/stores/settings";
|
||||
|
||||
export default function Page() {
|
||||
const backendUrl = useAuthStore((state) => state.backendUrl);
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [deviceName, setDeviceName] = useState("");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ["login", backendUrl, passphrase, deviceName],
|
||||
mutationFn: () =>
|
||||
login({
|
||||
mnemonic: passphrase,
|
||||
userData: {
|
||||
device: deviceName,
|
||||
},
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
return router.push("/(tabs)/movie-web");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ScreenLayout
|
||||
showHeader={false}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Login to your account
|
||||
</H4>
|
||||
|
||||
<Paragraph
|
||||
color="$ash50"
|
||||
textAlign="center"
|
||||
fontWeight="$semibold"
|
||||
paddingVertical="$4"
|
||||
>
|
||||
Please enter your passphrase to login to your account
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
<YStack paddingBottom="$5">
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">12-Word passphrase</Label>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
placeholder="Passphrase"
|
||||
secureTextEntry
|
||||
autoCorrect={false}
|
||||
value={passphrase}
|
||||
onChangeText={setPassphrase}
|
||||
/>
|
||||
</YStack>
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">Device name</Label>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
placeholder="Personal phone"
|
||||
autoCorrect={false}
|
||||
value={deviceName}
|
||||
onChangeText={setDeviceName}
|
||||
/>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<MWCard.Footer
|
||||
padded
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
gap="$4"
|
||||
>
|
||||
<MWButton
|
||||
type="purple"
|
||||
onPress={() => mutation.mutate()}
|
||||
isLoading={mutation.isPending}
|
||||
>
|
||||
Login
|
||||
</MWButton>
|
||||
{mutation.isError && (
|
||||
<Text color="$rose200" textAlign="center">
|
||||
{mutation.error.message}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
|
||||
Don't have an account yet?{"\n"}
|
||||
<Link href={{ pathname: "/sync/register" }} asChild>
|
||||
<Text color="$purple100" fontWeight="$bold">
|
||||
Create an account.
|
||||
</Text>
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
116
apps/expo/src/app/sync/register/account.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { H4, Label, Paragraph, View, YStack } from "tamagui";
|
||||
|
||||
import { Avatar } from "~/components/account/Avatar";
|
||||
import { ColorPicker, colors } from "~/components/account/ColorPicker";
|
||||
import {
|
||||
expoIcons,
|
||||
getDbIconFromExpoIcon,
|
||||
UserIconPicker,
|
||||
} from "~/components/account/UserIconPicker";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
import { MWInput } from "~/components/ui/Input";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
const [deviceName, setDeviceName] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [colorA, setColorA] = useState<string>(colors[0]);
|
||||
const [colorB, setColorB] = useState<string>(colors[0]);
|
||||
const [icon, setIcon] = useState<string>(expoIcons[0]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (!deviceName) {
|
||||
setErrorMessage("Please enter a device name");
|
||||
return;
|
||||
}
|
||||
return router.push({
|
||||
pathname: "/sync/register/confirm",
|
||||
params: {
|
||||
deviceName,
|
||||
colorA,
|
||||
colorB,
|
||||
icon: getDbIconFromExpoIcon(icon),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenLayout
|
||||
showHeader={false}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<View alignItems="center" marginBottom="$3">
|
||||
<Avatar colorA={colorA} colorB={colorB} icon={icon} />
|
||||
</View>
|
||||
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Account information
|
||||
</H4>
|
||||
|
||||
<Paragraph
|
||||
color="$shade200"
|
||||
textAlign="center"
|
||||
fontWeight="$normal"
|
||||
paddingTop="$4"
|
||||
>
|
||||
Enter a name for your device and pick colours and a user icon of
|
||||
your choosing
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
<YStack paddingBottom="$5">
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">Device name</Label>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
placeholder="Personal phone"
|
||||
autoCorrect={false}
|
||||
value={deviceName}
|
||||
onChangeText={setDeviceName}
|
||||
/>
|
||||
</YStack>
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">Profile color one</Label>
|
||||
<ColorPicker value={colorA} onInput={(color) => setColorA(color)} />
|
||||
</YStack>
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">Profile color two</Label>
|
||||
<ColorPicker value={colorB} onInput={(color) => setColorB(color)} />
|
||||
</YStack>
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">User icon</Label>
|
||||
<UserIconPicker value={icon} onInput={(icon) => setIcon(icon)} />
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
|
||||
{errorMessage && (
|
||||
<Paragraph color="$rose200" textAlign="center">
|
||||
{errorMessage}
|
||||
</Paragraph>
|
||||
)}
|
||||
<MWButton type="purple" width="100%" onPress={handleNext}>
|
||||
Next
|
||||
</MWButton>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
109
apps/expo/src/app/sync/register/confirm.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { H4, Label, Paragraph, YStack } from "tamagui";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
import { MWInput } from "~/components/ui/Input";
|
||||
import { useAuth } from "~/hooks/useAuth";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
// Requires type casting, typecheck fails for type-safe params
|
||||
const { deviceName, colorA, colorB, icon } =
|
||||
useLocalSearchParams() as unknown as {
|
||||
deviceName: string;
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: string;
|
||||
};
|
||||
const { register } = useAuth();
|
||||
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ["register", deviceName, colorA, colorB, icon],
|
||||
mutationFn: () =>
|
||||
register({
|
||||
// TODO: "Add recaptchaToken",
|
||||
mnemonic: passphrase,
|
||||
userData: {
|
||||
device: deviceName,
|
||||
profile: { colorA, colorB, icon },
|
||||
},
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
return router.push("/(tabs)/movie-web");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ScreenLayout
|
||||
showHeader={false}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Confirm your passphrase
|
||||
</H4>
|
||||
|
||||
<Paragraph
|
||||
color="$shade200"
|
||||
textAlign="center"
|
||||
fontWeight="$normal"
|
||||
paddingTop="$4"
|
||||
>
|
||||
Please enter your passphrase from earlier to confirm you have saved
|
||||
it and to create your account
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
<YStack paddingBottom="$5">
|
||||
<YStack gap="$1">
|
||||
<Label fontWeight="$bold">12-Word passphrase</Label>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
placeholder="Passphrase"
|
||||
secureTextEntry
|
||||
autoCorrect={false}
|
||||
value={passphrase}
|
||||
onChangeText={setPassphrase}
|
||||
/>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
|
||||
{mutation.isError && (
|
||||
<Paragraph color="$rose200" textAlign="center">
|
||||
{mutation.error.message}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
<MWButton
|
||||
type="purple"
|
||||
onPress={() => mutation.mutate()}
|
||||
isLoading={mutation.isPending}
|
||||
>
|
||||
Create account
|
||||
</MWButton>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
138
apps/expo/src/app/sync/register/index.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { H4, Paragraph, Text, useTheme, View, XStack, YStack } from "tamagui";
|
||||
|
||||
import { genMnemonic } from "@movie-web/api";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
|
||||
function PassphraseWord({ word }: { word: string }) {
|
||||
return (
|
||||
<View
|
||||
width="$10"
|
||||
borderRadius="$4"
|
||||
paddingHorizontal="$4"
|
||||
paddingVertical="$3"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="$shade400"
|
||||
>
|
||||
<Text fontWeight="$bold">{word}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const theme = useTheme();
|
||||
const words = genMnemonic();
|
||||
|
||||
return (
|
||||
<ScreenLayout
|
||||
showHeader={false}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Your passphrase
|
||||
</H4>
|
||||
|
||||
<Paragraph
|
||||
color="$shade200"
|
||||
textAlign="center"
|
||||
fontWeight="$normal"
|
||||
paddingTop="$4"
|
||||
>
|
||||
Your passphrase acts as your username and password. Make sure to
|
||||
keep it safe as you will need to enter it to login to your account
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
<YStack
|
||||
borderRadius="$4"
|
||||
borderColor="$shade200"
|
||||
borderWidth="$0.5"
|
||||
marginBottom="$4"
|
||||
>
|
||||
<XStack
|
||||
gap="$1"
|
||||
borderBottomWidth="$0.5"
|
||||
borderColor="$shade200"
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$4"
|
||||
>
|
||||
<Text fontWeight="$bold" flexGrow={1}>
|
||||
Passphrase
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
onPress={async () => {
|
||||
await Clipboard.setStringAsync(words);
|
||||
}}
|
||||
>
|
||||
<Feather name="copy" size={18} color={theme.shade200.val} />
|
||||
<Text color="$shade200" fontWeight="$bold">
|
||||
Copy
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</XStack>
|
||||
<View
|
||||
flexWrap="wrap"
|
||||
flexDirection="row"
|
||||
gap="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="$3"
|
||||
>
|
||||
{words.split(" ").map((word, index) => (
|
||||
<PassphraseWord key={index} word={word} />
|
||||
))}
|
||||
</View>
|
||||
</YStack>
|
||||
|
||||
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/sync/register/account",
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<MWButton type="purple">I have saved my passphrase</MWButton>
|
||||
</Link>
|
||||
|
||||
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
|
||||
Already have an account?{"\n"}
|
||||
<Text color="$purple100" fontWeight="$bold">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/sync/login",
|
||||
}}
|
||||
>
|
||||
Login here
|
||||
</Link>
|
||||
</Text>
|
||||
</Paragraph>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
122
apps/expo/src/app/sync/trust/[backendUrl].tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Link, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { H4, Paragraph, Text, View } from "tamagui";
|
||||
|
||||
import { getBackendMeta } from "@movie-web/api";
|
||||
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWCard } from "~/components/ui/Card";
|
||||
import { useAuthStore } from "~/stores/settings";
|
||||
|
||||
export default function Page() {
|
||||
const { backendUrl } = useLocalSearchParams<{ backendUrl: string }>();
|
||||
|
||||
const setBackendUrl = useAuthStore((state) => state.setBackendUrl);
|
||||
|
||||
const meta = useQuery({
|
||||
queryKey: ["backendMeta", backendUrl],
|
||||
queryFn: () => getBackendMeta(backendUrl as unknown as string),
|
||||
});
|
||||
|
||||
return (
|
||||
<ScreenLayout
|
||||
showHeader={false}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header padded>
|
||||
<H4 fontWeight="$bold" textAlign="center">
|
||||
Do you trust this server?
|
||||
</H4>
|
||||
|
||||
<Paragraph
|
||||
color="$ash50"
|
||||
textAlign="center"
|
||||
fontWeight="$semibold"
|
||||
paddingVertical="$4"
|
||||
>
|
||||
{meta.isLoading && "Loading..."}
|
||||
{meta.isError && "Error loading metadata"}
|
||||
{meta.isSuccess && (
|
||||
<>
|
||||
You are connecting to{" "}
|
||||
<Text
|
||||
fontWeight="$bold"
|
||||
color="white"
|
||||
textDecorationLine="underline"
|
||||
>
|
||||
{backendUrl}
|
||||
</Text>
|
||||
. Please confirm you trust it before making an account.
|
||||
</>
|
||||
)}
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
{meta.isSuccess && (
|
||||
<View
|
||||
borderColor="$shade200"
|
||||
borderWidth="$0.5"
|
||||
borderRadius="$8"
|
||||
paddingHorizontal="$5"
|
||||
paddingVertical="$4"
|
||||
width="90%"
|
||||
alignSelf="center"
|
||||
>
|
||||
<Text
|
||||
fontWeight="$bold"
|
||||
paddingBottom="$1"
|
||||
textAlign="center"
|
||||
fontSize="$4"
|
||||
>
|
||||
{meta.data.name}
|
||||
</Text>
|
||||
|
||||
<Paragraph color="$ash50" textAlign="center">
|
||||
{meta.data.description}
|
||||
</Paragraph>
|
||||
</View>
|
||||
)}
|
||||
<MWCard.Footer
|
||||
padded
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
gap="$4"
|
||||
>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/sync/register",
|
||||
}}
|
||||
asChild
|
||||
onPress={() => {
|
||||
setBackendUrl(backendUrl as unknown as string);
|
||||
}}
|
||||
>
|
||||
<MWButton type="purple" disabled={!meta.isSuccess}>
|
||||
I trust this server
|
||||
</MWButton>
|
||||
</Link>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/(tabs)/",
|
||||
}}
|
||||
replace
|
||||
asChild
|
||||
>
|
||||
<MWButton type="cancel">Go back</MWButton>
|
||||
</Link>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
45
apps/expo/src/app/videoPlayer.tsx
Normal 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 />;
|
||||
}
|
||||
}
|
35
apps/expo/src/components/BrandPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
201
apps/expo/src/components/DownloadItem.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { NativeSyntheticEvent } from "react-native";
|
||||
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
|
||||
import React from "react";
|
||||
import ContextMenu from "react-native-context-menu-view";
|
||||
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Image, Text, View, XStack, YStack } from "tamagui";
|
||||
|
||||
import type { Download, DownloadContent } from "~/hooks/useDownloadManager";
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
import { mapSeasonAndEpisodeNumberToText } from "./player/utils";
|
||||
import { MWProgress } from "./ui/Progress";
|
||||
import { FlashingText } from "./ui/Text";
|
||||
|
||||
export interface DownloadItemProps {
|
||||
item: Download;
|
||||
onPress: (localPath?: string) => void;
|
||||
}
|
||||
|
||||
enum ContextMenuActions {
|
||||
Cancel = "Cancel",
|
||||
Remove = "Remove",
|
||||
}
|
||||
|
||||
const statusToTextMap: Record<Download["status"], string> = {
|
||||
downloading: "Downloading",
|
||||
finished: "Finished",
|
||||
error: "Error",
|
||||
merging: "Merging",
|
||||
cancelled: "Cancelled",
|
||||
importing: "Importing",
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
export function DownloadItem(props: DownloadItemProps) {
|
||||
const percentage = props.item.progress * 100;
|
||||
const formattedFileSize = formatBytes(props.item.fileSize);
|
||||
const formattedDownloaded = formatBytes(props.item.downloaded);
|
||||
const { removeDownload, cancelDownload } = useDownloadManager();
|
||||
|
||||
const contextMenuActions = [
|
||||
{
|
||||
title: ContextMenuActions.Remove,
|
||||
},
|
||||
...(props.item.status !== "finished"
|
||||
? [{ title: ContextMenuActions.Cancel }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const onContextMenuPress = (
|
||||
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
|
||||
) => {
|
||||
if (e.nativeEvent.name === ContextMenuActions.Cancel) {
|
||||
void cancelDownload(props.item);
|
||||
} else if (e.nativeEvent.name === ContextMenuActions.Remove) {
|
||||
removeDownload(props.item);
|
||||
}
|
||||
};
|
||||
|
||||
const isInProgress = !(
|
||||
props.item.status === "finished" ||
|
||||
props.item.status === "error" ||
|
||||
props.item.status === "cancelled"
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
actions={contextMenuActions}
|
||||
onPress={onContextMenuPress}
|
||||
previewBackgroundColor="transparent"
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => props.onPress(props.item.localPath)}
|
||||
onLongPress={() => {
|
||||
return;
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<View
|
||||
aspectRatio={9 / 14}
|
||||
width={70}
|
||||
maxHeight={180}
|
||||
overflow="hidden"
|
||||
borderRadius="$2"
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
|
||||
}}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</View>
|
||||
<YStack gap="$2" flex={1}>
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<Text
|
||||
fontWeight="$bold"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
flex={1}
|
||||
>
|
||||
{props.item.media.type === "show" &&
|
||||
`${mapSeasonAndEpisodeNumberToText(
|
||||
props.item.media.season.number,
|
||||
props.item.media.episode.number,
|
||||
)} `}
|
||||
{props.item.media.title}
|
||||
</Text>
|
||||
{props.item.type !== "hls" && (
|
||||
<Text fontSize="$2" color="gray">
|
||||
{props.item.speed.toFixed(2)} MB/s
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
<MWProgress value={percentage} height={10} maxWidth="100%">
|
||||
<MWProgress.Indicator />
|
||||
</MWProgress>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$2" color="gray">
|
||||
{props.item.type === "hls"
|
||||
? `${percentage.toFixed()}% - ${props.item.downloaded} of ${props.item.fileSize} segments`
|
||||
: `${percentage.toFixed()}% - ${formattedDownloaded} of ${formattedFileSize}`}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<FlashingText
|
||||
isInProgress={isInProgress}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "gray",
|
||||
}}
|
||||
>
|
||||
{statusToTextMap[props.item.status]}
|
||||
</FlashingText>
|
||||
</View>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShowDownloadItem({ download }: { download: DownloadContent }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(downloads)/[tmdbId]",
|
||||
params: { tmdbId: download.media.tmdbId },
|
||||
})
|
||||
}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<View
|
||||
aspectRatio={9 / 14}
|
||||
width={70}
|
||||
maxHeight={180}
|
||||
overflow="hidden"
|
||||
borderRadius="$2"
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
|
||||
}}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</View>
|
||||
<YStack gap="$2">
|
||||
<YStack gap="$1">
|
||||
<Text fontWeight="$bold" ellipse flexGrow={1} fontSize="$5">
|
||||
{download.media.title}
|
||||
</Text>
|
||||
<Text fontSize="$2">
|
||||
{download.downloads.length} Episode
|
||||
{download.downloads.length > 1 ? "s" : ""} |{" "}
|
||||
{formatBytes(
|
||||
download.downloads.reduce(
|
||||
(acc, curr) => acc + curr.fileSize,
|
||||
0,
|
||||
),
|
||||
)}
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
15
apps/expo/src/components/FlagIcon.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
28
apps/expo/src/components/Icon.tsx
Normal 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>
|
||||
);
|
||||
};
|
23
apps/expo/src/components/SvgTabBarIcon.tsx
Normal 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;
|
||||
}
|
12
apps/expo/src/components/TabBarIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { FontAwesome } from "@expo/vector-icons";
|
||||
import { useTheme } from "tamagui";
|
||||
|
||||
type Props = {
|
||||
focused?: boolean;
|
||||
} & React.ComponentProps<typeof FontAwesome>;
|
||||
|
||||
export default function TabBarIcon({ focused, ...rest }: Props) {
|
||||
const theme = useTheme();
|
||||
const color = focused ? theme.tabBarIconFocused.val : theme.tabBarIcon.val;
|
||||
return <FontAwesome color={color} size={24} {...rest} />;
|
||||
}
|
306
apps/expo/src/components/account/AccountInformation.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { H5, Spinner, Text, View, XStack, YStack } from "tamagui";
|
||||
|
||||
import {
|
||||
base64ToBuffer,
|
||||
decryptData,
|
||||
editUser,
|
||||
encryptData,
|
||||
getSessions,
|
||||
removeSession,
|
||||
updateSession,
|
||||
updateSettings,
|
||||
} from "@movie-web/api";
|
||||
|
||||
import { useAuth } from "~/hooks/useAuth";
|
||||
import { useSettingsState } from "~/hooks/useSettingsState";
|
||||
import { useAuthStore } from "~/stores/settings";
|
||||
import ScreenLayout from "../layout/ScreenLayout";
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { MWCard } from "../ui/Card";
|
||||
import { MWInput } from "../ui/Input";
|
||||
import { MWSeparator } from "../ui/Separator";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { ChangeProfileModal } from "./ChangeProfileModal";
|
||||
import { DeleteAccountAlert } from "./DeleteAccountAlert";
|
||||
import { getDbIconFromExpoIcon, getExpoIconFromDbIcon } from "./UserIconPicker";
|
||||
|
||||
export function AccountInformation() {
|
||||
const account = useAuthStore((state) => state.account);
|
||||
const backendUrl = useAuthStore((state) => state.backendUrl);
|
||||
const proxySet = useAuthStore((s) => s.proxySet);
|
||||
const setProxySet = useAuthStore((s) => s.setProxySet);
|
||||
const updateProfile = useAuthStore((s) => s.setAccountProfile);
|
||||
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { decryptedName, logout } = useAuth();
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ["logout"],
|
||||
mutationFn: logout,
|
||||
});
|
||||
|
||||
const removeSessionMutation = useMutation({
|
||||
mutationKey: ["removeSession"],
|
||||
mutationFn: (sessionId: string) =>
|
||||
removeSession(backendUrl, account!.token, sessionId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["sessions", backendUrl, account],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sessions = useQuery({
|
||||
queryKey: ["sessions", backendUrl, account],
|
||||
queryFn: () => getSessions(backendUrl, account!),
|
||||
enabled: !!account,
|
||||
});
|
||||
|
||||
const deviceListSorted = useMemo(() => {
|
||||
if (!sessions.data || !account) return [];
|
||||
let list =
|
||||
sessions.data?.map((session) => {
|
||||
const decryptedName = decryptData(
|
||||
session.device,
|
||||
base64ToBuffer(account.seed),
|
||||
);
|
||||
return {
|
||||
current: session.id === account.sessionId,
|
||||
id: session.id,
|
||||
name: decryptedName,
|
||||
};
|
||||
}) ?? [];
|
||||
list = list.sort((a, b) => {
|
||||
if (a.current) return -1;
|
||||
if (b.current) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return list;
|
||||
}, [sessions.data, account]);
|
||||
|
||||
const state = useSettingsState(decryptedName, proxySet, account?.profile);
|
||||
|
||||
const saveChanges = useCallback(async () => {
|
||||
if (account && backendUrl) {
|
||||
if (state.proxyUrls.changed) {
|
||||
await updateSettings(backendUrl, account, {
|
||||
proxyUrls: state.proxyUrls.state?.filter((v) => v !== "") ?? null,
|
||||
});
|
||||
}
|
||||
if (state.deviceName.changed) {
|
||||
const newDeviceName = await encryptData(
|
||||
state.deviceName.state,
|
||||
base64ToBuffer(account.seed),
|
||||
);
|
||||
await updateSession(backendUrl, account, {
|
||||
deviceName: newDeviceName,
|
||||
});
|
||||
updateDeviceName(newDeviceName);
|
||||
}
|
||||
if (state.profile.changed) {
|
||||
await editUser(backendUrl, account, {
|
||||
profile: state.profile.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setProxySet(state.proxyUrls.state?.filter((v) => v !== "") ?? null);
|
||||
|
||||
if (state.profile.state) {
|
||||
updateProfile(state.profile.state);
|
||||
}
|
||||
}, [
|
||||
account,
|
||||
backendUrl,
|
||||
setProxySet,
|
||||
state.deviceName.changed,
|
||||
state.deviceName.state,
|
||||
state.profile.changed,
|
||||
state.profile.state,
|
||||
state.proxyUrls.changed,
|
||||
state.proxyUrls.state,
|
||||
updateDeviceName,
|
||||
updateProfile,
|
||||
]);
|
||||
|
||||
const saveChangesMutation = useMutation({
|
||||
mutationKey: ["saveChanges"],
|
||||
mutationFn: saveChanges,
|
||||
});
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScreenLayout>
|
||||
<YStack gap="$6">
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Account
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
|
||||
{state.profile.state && (
|
||||
<MWCard bordered padded>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<ChangeProfileModal
|
||||
colorA={state.profile.state.colorA}
|
||||
setColorA={(v) => {
|
||||
state.profile.set((s) =>
|
||||
s ? { ...s, colorA: v } : undefined,
|
||||
);
|
||||
}}
|
||||
colorB={state.profile.state.colorB}
|
||||
setColorB={(v) =>
|
||||
state.profile.set((s) =>
|
||||
s ? { ...s, colorB: v } : undefined,
|
||||
)
|
||||
}
|
||||
icon={state.profile.state.icon}
|
||||
setUserIcon={(v) =>
|
||||
state.profile.set((s) =>
|
||||
s
|
||||
? { ...s, icon: getDbIconFromExpoIcon(v) }
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
<Avatar
|
||||
{...state.profile.state}
|
||||
icon={getExpoIconFromDbIcon(state.profile.state.icon)}
|
||||
width="$7"
|
||||
height="$7"
|
||||
bottomItem={
|
||||
<XStack
|
||||
backgroundColor="$shade200"
|
||||
px="$2"
|
||||
py="$1"
|
||||
borderRadius="$4"
|
||||
gap="$1.5"
|
||||
alignItems="center"
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<MaterialIcons name="edit" size={10} color="white" />
|
||||
<Text fontSize="$2">Edit</Text>
|
||||
</XStack>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
/>
|
||||
<YStack gap="$4">
|
||||
<Text fontWeight="$bold">Device name</Text>
|
||||
<MWInput
|
||||
type="authentication"
|
||||
value={state.deviceName.state}
|
||||
onChangeText={state.deviceName.set}
|
||||
alignSelf="flex-start"
|
||||
width="$14"
|
||||
/>
|
||||
|
||||
<MWButton
|
||||
type="danger"
|
||||
onPress={() => logoutMutation.mutate()}
|
||||
alignSelf="flex-start"
|
||||
isLoading={logoutMutation.isPending}
|
||||
>
|
||||
Logout
|
||||
</MWButton>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</MWCard>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Devices
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
{sessions.isLoading && <Spinner />}
|
||||
{sessions.isError && (
|
||||
<Text fontWeight="$bold" color="$rose200">
|
||||
Error loading sessions
|
||||
</Text>
|
||||
)}
|
||||
{deviceListSorted.map((device) => (
|
||||
<MWCard bordered padded key={device.id}>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<YStack gap="$1" flexGrow={1}>
|
||||
<Text fontWeight="$semibold" color="$ash300">
|
||||
Device name
|
||||
</Text>
|
||||
<Text fontWeight="$bold">{device.name}</Text>
|
||||
</YStack>
|
||||
{!device.current && (
|
||||
<MWButton
|
||||
type="danger"
|
||||
isLoading={removeSessionMutation.isPending}
|
||||
onPress={() => removeSessionMutation.mutate(device.id)}
|
||||
>
|
||||
Remove
|
||||
</MWButton>
|
||||
)}
|
||||
</XStack>
|
||||
</MWCard>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$4">
|
||||
<Text fontSize="$7" fontWeight="$bold">
|
||||
Actions
|
||||
</Text>
|
||||
<MWSeparator />
|
||||
<MWCard bordered padded>
|
||||
<YStack gap="$3">
|
||||
<H5 fontWeight="$bold">Delete account</H5>
|
||||
<Text color="$ash300" fontWeight="$semibold">
|
||||
This action is irreversible. All data will be deleted and
|
||||
nothing can be recovered.
|
||||
</Text>
|
||||
<DeleteAccountAlert />
|
||||
</YStack>
|
||||
</MWCard>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</ScreenLayout>
|
||||
|
||||
{state.changed && (
|
||||
<View position="absolute" alignItems="center" bottom="$2" px="$2">
|
||||
<XStack
|
||||
width="100%"
|
||||
padding="$4"
|
||||
backgroundColor="$shade800"
|
||||
justifyContent="space-between"
|
||||
borderRadius="$4"
|
||||
animation="bounce"
|
||||
enterStyle={{
|
||||
y: 10,
|
||||
opacity: 0,
|
||||
}}
|
||||
opacity={1}
|
||||
scale={1}
|
||||
>
|
||||
<MWButton type="cancel" onPress={state.reset}>
|
||||
Reset
|
||||
</MWButton>
|
||||
<MWButton
|
||||
type="purple"
|
||||
onPress={() => saveChangesMutation.mutate()}
|
||||
isLoading={saveChangesMutation.isPending}
|
||||
>
|
||||
Save changes
|
||||
</MWButton>
|
||||
</XStack>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
40
apps/expo/src/components/account/Avatar.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { CircleProps } from "tamagui";
|
||||
import { FontAwesome6 } from "@expo/vector-icons";
|
||||
import { Circle, View } from "tamagui";
|
||||
import { LinearGradient } from "tamagui/linear-gradient";
|
||||
|
||||
export interface AvatarProps {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: string;
|
||||
bottomItem?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Avatar(props: AvatarProps & CircleProps) {
|
||||
return (
|
||||
<Circle
|
||||
backgroundColor={props.colorA}
|
||||
height="$6"
|
||||
width="$6"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
{...props}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[props.colorA, props.colorB]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
borderRadius="$12"
|
||||
width="100%"
|
||||
height="100%"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<FontAwesome6 name={props.icon} size={24} color="white" />
|
||||
</LinearGradient>
|
||||
<View position="absolute" bottom={0}>
|
||||
{props.bottomItem}
|
||||
</View>
|
||||
</Circle>
|
||||
);
|
||||
}
|
81
apps/expo/src/components/account/ChangeProfileModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { H3, Sheet, Text, XStack, YStack } from "tamagui";
|
||||
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { ColorPicker } from "./ColorPicker";
|
||||
import { UserIconPicker } from "./UserIconPicker";
|
||||
|
||||
export function ChangeProfileModal(props: {
|
||||
colorA: string;
|
||||
setColorA: (s: string) => void;
|
||||
colorB: string;
|
||||
setColorB: (s: string) => void;
|
||||
icon: string;
|
||||
setUserIcon: (s: string) => void;
|
||||
|
||||
open: boolean;
|
||||
setOpen: (b: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Sheet
|
||||
forceRemoveScrollEnabled={props.open}
|
||||
modal
|
||||
open={props.open}
|
||||
onOpenChange={props.setOpen}
|
||||
dismissOnSnapToBottom
|
||||
dismissOnOverlayPress
|
||||
animation="fast"
|
||||
snapPoints={[65]}
|
||||
>
|
||||
<Sheet.Handle backgroundColor="$shade100" />
|
||||
<Sheet.Frame
|
||||
backgroundColor="$shade800"
|
||||
padding="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<YStack padding="$4" gap="$4">
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<H3 flexGrow={1} fontWeight="$bold">
|
||||
Edit profile picture
|
||||
</H3>
|
||||
<Avatar
|
||||
colorA={props.colorA}
|
||||
colorB={props.colorB}
|
||||
icon={props.icon}
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontWeight="$bold">Profile color one</Text>
|
||||
<ColorPicker value={props.colorA} onInput={props.setColorA} />
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontWeight="$bold">Profile color two</Text>
|
||||
<ColorPicker value={props.colorB} onInput={props.setColorB} />
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontWeight="$bold">User icon</Text>
|
||||
<UserIconPicker value={props.icon} onInput={props.setUserIcon} />
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<MWButton
|
||||
type="purple"
|
||||
width="100%"
|
||||
onPress={() => props.setOpen(false)}
|
||||
>
|
||||
Finish editing
|
||||
</MWButton>
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
39
apps/expo/src/components/account/ColorPicker.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { View, XStack } from "tamagui";
|
||||
|
||||
export const colors = [
|
||||
"#0A54FF",
|
||||
"#CF2E68",
|
||||
"#F9DD7F",
|
||||
"#7652DD",
|
||||
"#2ECFA8",
|
||||
] as const;
|
||||
|
||||
export function ColorPicker(props: {
|
||||
value: string;
|
||||
onInput: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$2">
|
||||
{colors.map((color) => {
|
||||
return (
|
||||
<View
|
||||
onPress={() => props.onInput(color)}
|
||||
flexGrow={1}
|
||||
height="$4"
|
||||
borderRadius="$4"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
backgroundColor={color}
|
||||
key={color}
|
||||
>
|
||||
{props.value === color ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="white" />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
91
apps/expo/src/components/account/DeleteAccountAlert.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertDialog, XStack, YStack } from "tamagui";
|
||||
|
||||
import { deleteUser } from "@movie-web/api";
|
||||
|
||||
import { useAuth } from "~/hooks/useAuth";
|
||||
import { useAuthStore } from "~/stores/settings";
|
||||
import { MWButton } from "../ui/Button";
|
||||
|
||||
export function DeleteAccountAlert() {
|
||||
const account = useAuthStore((state) => state.account);
|
||||
const backendUrl = useAuthStore((state) => state.backendUrl);
|
||||
const { logout } = useAuth();
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ["logout"],
|
||||
mutationFn: logout,
|
||||
});
|
||||
|
||||
const deleteAccountMutation = useMutation({
|
||||
mutationKey: ["deleteAccount"],
|
||||
mutationFn: () => deleteUser(backendUrl, account!),
|
||||
onSuccess: () => {
|
||||
logoutMutation.mutate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog native>
|
||||
<AlertDialog.Trigger asChild>
|
||||
<MWButton type="danger" width="$14" alignSelf="flex-end">
|
||||
Delete account
|
||||
</MWButton>
|
||||
</AlertDialog.Trigger>
|
||||
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay
|
||||
key="overlay"
|
||||
animation="quick"
|
||||
opacity={0.5}
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
<AlertDialog.Content
|
||||
bordered
|
||||
elevate
|
||||
key="content"
|
||||
animation={[
|
||||
"quick",
|
||||
{
|
||||
opacity: {
|
||||
overshootClamping: true,
|
||||
},
|
||||
},
|
||||
]}
|
||||
enterStyle={{ x: 0, y: -20, opacity: 0, scale: 0.9 }}
|
||||
exitStyle={{ x: 0, y: 10, opacity: 0, scale: 0.95 }}
|
||||
x={0}
|
||||
scale={1}
|
||||
opacity={1}
|
||||
y={0}
|
||||
>
|
||||
<YStack gap="$4">
|
||||
<AlertDialog.Title>Are you sure?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This action is irreversible. All data will be deleted and nothing
|
||||
can be recovered.
|
||||
</AlertDialog.Description>
|
||||
|
||||
<XStack gap="$3" justifyContent="flex-end">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<MWButton>Cancel</MWButton>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
asChild
|
||||
onPress={() => deleteAccountMutation.mutate()}
|
||||
>
|
||||
<MWButton
|
||||
type="purple"
|
||||
isLoading={deleteAccountMutation.isPending}
|
||||
>
|
||||
I am sure
|
||||
</MWButton>
|
||||
</AlertDialog.Action>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
60
apps/expo/src/components/account/GetStarted.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Link } from "expo-router";
|
||||
import { H3, H5, Paragraph, View } from "tamagui";
|
||||
|
||||
import { useAuthStore } from "~/stores/settings";
|
||||
import ScreenLayout from "../layout/ScreenLayout";
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { MWCard } from "../ui/Card";
|
||||
import { MWInput } from "../ui/Input";
|
||||
|
||||
export function AccountGetStarted() {
|
||||
const { backendUrl, setBackendUrl } = useAuthStore();
|
||||
|
||||
return (
|
||||
<ScreenLayout
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MWCard bordered padded>
|
||||
<MWCard.Header>
|
||||
<H3 fontWeight="$bold" paddingBottom="$1">
|
||||
Sync to the cloud
|
||||
</H3>
|
||||
<H5 color="$shade200" fontWeight="$semibold" paddingVertical="$3">
|
||||
Share your watch progress between devices and keep them synced.
|
||||
</H5>
|
||||
<Paragraph color="$shade200">
|
||||
First choose the backend you want to use. If you do not know what
|
||||
this does, use the default and click on 'Get started'.
|
||||
</Paragraph>
|
||||
</MWCard.Header>
|
||||
|
||||
<View padding="$4">
|
||||
<MWInput
|
||||
placeholder={backendUrl}
|
||||
type="authentication"
|
||||
value={backendUrl}
|
||||
onChangeText={setBackendUrl}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<MWCard.Footer padded justifyContent="center">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/sync/trust/[backendUrl]",
|
||||
params: { backendUrl },
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<MWButton type="purple" width="100%">
|
||||
Get started
|
||||
</MWButton>
|
||||
</Link>
|
||||
</MWCard.Footer>
|
||||
</MWCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
57
apps/expo/src/components/account/UserIconPicker.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { FontAwesome6 } from "@expo/vector-icons";
|
||||
import { View, XStack } from "tamagui";
|
||||
|
||||
export const expoIcons = [
|
||||
"user-group",
|
||||
"couch",
|
||||
"mobile-screen",
|
||||
"ticket",
|
||||
"handcuffs",
|
||||
] as const;
|
||||
|
||||
export const expoIconsToDbIcons: Record<(typeof expoIcons)[number], string> = {
|
||||
"user-group": "userGroup",
|
||||
couch: "couch",
|
||||
"mobile-screen": "mobile",
|
||||
ticket: "ticket",
|
||||
handcuffs: "handcuffs",
|
||||
};
|
||||
|
||||
export const getExpoIconFromDbIcon = (icon: string) => {
|
||||
return Object.keys(expoIconsToDbIcons).find(
|
||||
(key) => expoIconsToDbIcons[key as (typeof expoIcons)[number]] === icon,
|
||||
) as (typeof expoIcons)[number];
|
||||
};
|
||||
|
||||
export const getDbIconFromExpoIcon = (icon: string) => {
|
||||
return expoIconsToDbIcons[icon as (typeof expoIcons)[number]];
|
||||
};
|
||||
|
||||
export function UserIconPicker(props: {
|
||||
value: string;
|
||||
onInput: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$2">
|
||||
{expoIcons.map((icon) => {
|
||||
return (
|
||||
<View
|
||||
flexGrow={1}
|
||||
height="$4"
|
||||
borderRadius="$4"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
backgroundColor={props.value === icon ? "$purple400" : "$shade400"}
|
||||
borderColor={props.value === icon ? "$purple200" : "$shade400"}
|
||||
borderWidth={1}
|
||||
key={icon}
|
||||
onPress={() => props.onInput(icon)}
|
||||
>
|
||||
<FontAwesome6 name={icon} size={24} color="white" />
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
33
apps/expo/src/components/item/ItemListSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
152
apps/expo/src/components/item/item.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { NativeSyntheticEvent } from "react-native";
|
||||
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
|
||||
import { useCallback } from "react";
|
||||
import { Keyboard, TouchableOpacity } from "react-native";
|
||||
import ContextMenu from "react-native-context-menu-view";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Image, Text, View } from "tamagui";
|
||||
|
||||
import { useToast } from "~/hooks/useToast";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
|
||||
|
||||
export interface ItemData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: "movie" | "tv";
|
||||
season?: number;
|
||||
episode?: number;
|
||||
year: number;
|
||||
release_date?: Date;
|
||||
posterUrl: string;
|
||||
}
|
||||
|
||||
enum ContextMenuActions {
|
||||
Bookmark = "Bookmark",
|
||||
RemoveBookmark = "Remove Bookmark",
|
||||
Download = "Download",
|
||||
RemoveWatchHistoryItem = "Remove from Continue Watching",
|
||||
}
|
||||
|
||||
function checkReleased(media: ItemData): boolean {
|
||||
const isReleasedYear = Boolean(
|
||||
media.year && media.year <= new Date().getFullYear(),
|
||||
);
|
||||
const isReleasedDate = Boolean(
|
||||
media.release_date && media.release_date <= new Date(),
|
||||
);
|
||||
|
||||
// If the media has a release date, use that, otherwise use the year
|
||||
const isReleased = media.release_date ? isReleasedDate : isReleasedYear;
|
||||
|
||||
return isReleased;
|
||||
}
|
||||
|
||||
export default function Item({ data }: { data: ItemData }) {
|
||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||
const router = useRouter();
|
||||
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
|
||||
const { hasWatchHistoryItem, removeFromWatchHistory } =
|
||||
useWatchHistoryStore();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const { title, type, year, posterUrl } = data;
|
||||
|
||||
const isReleased = useCallback(() => checkReleased(data), [data]);
|
||||
|
||||
const handlePress = () => {
|
||||
if (!isReleased()) {
|
||||
showToast("This media is not released yet", {
|
||||
burntOptions: { preset: "error" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
resetVideo();
|
||||
Keyboard.dismiss();
|
||||
router.push({
|
||||
pathname: "/videoPlayer",
|
||||
params: { data: JSON.stringify(data) },
|
||||
});
|
||||
};
|
||||
|
||||
const contextMenuActions = [
|
||||
{
|
||||
title: isBookmarked(data)
|
||||
? ContextMenuActions.RemoveBookmark
|
||||
: ContextMenuActions.Bookmark,
|
||||
},
|
||||
...(type === "movie" ? [{ title: ContextMenuActions.Download }] : []),
|
||||
...(hasWatchHistoryItem(data)
|
||||
? [{ title: ContextMenuActions.RemoveWatchHistoryItem }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const onContextMenuPress = (
|
||||
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
|
||||
) => {
|
||||
if (e.nativeEvent.name === ContextMenuActions.Bookmark) {
|
||||
addBookmark(data);
|
||||
showToast("Added to bookmarks", {
|
||||
burntOptions: { preset: "done" },
|
||||
});
|
||||
} else if (e.nativeEvent.name === ContextMenuActions.RemoveBookmark) {
|
||||
removeBookmark(data);
|
||||
showToast("Removed from bookmarks", {
|
||||
burntOptions: { preset: "done" },
|
||||
});
|
||||
} else if (e.nativeEvent.name === ContextMenuActions.Download) {
|
||||
router.push({
|
||||
pathname: "/videoPlayer",
|
||||
params: { data: JSON.stringify(data), download: "true" },
|
||||
});
|
||||
} else if (
|
||||
e.nativeEvent.name === ContextMenuActions.RemoveWatchHistoryItem
|
||||
) {
|
||||
removeFromWatchHistory(data);
|
||||
showToast("Removed from Continue Watching", {
|
||||
burntOptions: { preset: "done" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onLongPress={() => {}}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<View width="100%">
|
||||
<ContextMenu actions={contextMenuActions} onPress={onContextMenuPress}>
|
||||
<View
|
||||
marginBottom={4}
|
||||
aspectRatio={9 / 14}
|
||||
width="100%"
|
||||
overflow="hidden"
|
||||
borderRadius={24}
|
||||
height="$14"
|
||||
>
|
||||
<Image source={{ uri: posterUrl }} width="100%" height="100%" />
|
||||
</View>
|
||||
</ContextMenu>
|
||||
<Text fontWeight="bold" fontSize={14}>
|
||||
{title}
|
||||
</Text>
|
||||
<View flexDirection="row" alignItems="center" gap={3}>
|
||||
<Text fontSize={12} color="$ash100">
|
||||
{type === "tv" ? "Show" : "Movie"}
|
||||
</Text>
|
||||
<View
|
||||
height={6}
|
||||
width={6}
|
||||
borderRadius={24}
|
||||
backgroundColor="$ash100"
|
||||
/>
|
||||
<Text fontSize={12} color="$ash100">
|
||||
{isReleased() ? year : "Unreleased"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
48
apps/expo/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Linking } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { FontAwesome6, MaterialIcons } from "@expo/vector-icons";
|
||||
import { Circle, View } from "tamagui";
|
||||
|
||||
import { DISCORD_LINK, GITHUB_LINK } from "~/constants/core";
|
||||
import { BrandPill } from "../BrandPill";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<View alignItems="center" gap="$3" flexDirection="row">
|
||||
<BrandPill />
|
||||
|
||||
<Circle
|
||||
backgroundColor="$pillBackground"
|
||||
size="$3.5"
|
||||
pressStyle={{
|
||||
opacity: 1,
|
||||
scale: 1.05,
|
||||
}}
|
||||
onPress={async () => {
|
||||
await Linking.openURL(DISCORD_LINK);
|
||||
}}
|
||||
onLongPress={() =>
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||
}
|
||||
>
|
||||
<MaterialIcons name="discord" size={28} color="white" />
|
||||
</Circle>
|
||||
<Circle
|
||||
backgroundColor="$pillBackground"
|
||||
size="$3.5"
|
||||
pressStyle={{
|
||||
opacity: 1,
|
||||
scale: 1.05,
|
||||
}}
|
||||
onPress={async () => {
|
||||
await Linking.openURL(GITHUB_LINK);
|
||||
}}
|
||||
onLongPress={() =>
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||
}
|
||||
>
|
||||
<FontAwesome6 name="github" size={28} color="white" />
|
||||
</Circle>
|
||||
</View>
|
||||
);
|
||||
}
|
48
apps/expo/src/components/layout/ScreenLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ScrollViewProps } from "tamagui";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ScrollView } from "tamagui";
|
||||
import { LinearGradient } from "tamagui/linear-gradient";
|
||||
|
||||
import { Header } from "./Header";
|
||||
|
||||
interface Props {
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
export default function ScreenLayout({
|
||||
children,
|
||||
showHeader = true,
|
||||
...props
|
||||
}: ScrollViewProps & Props) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
flex={1}
|
||||
paddingVertical="$4"
|
||||
paddingHorizontal="$4"
|
||||
colors={[
|
||||
"$shade900",
|
||||
"$purple900",
|
||||
"$purple800",
|
||||
"$shade700",
|
||||
"$shade900",
|
||||
]}
|
||||
locations={[0.02, 0.15, 0.2, 0.4, 0.8]}
|
||||
start={[0, 0]}
|
||||
end={[1, 1]}
|
||||
flexGrow={1}
|
||||
paddingTop={showHeader ? insets.top + 16 : insets.top + 50}
|
||||
>
|
||||
{showHeader && <Header />}
|
||||
<ScrollView
|
||||
marginTop="$4"
|
||||
flexGrow={1}
|
||||
showsVerticalScrollIndicator={false}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</LinearGradient>
|
||||
);
|
||||
}
|
116
apps/expo/src/components/player/AudioTrackSelector.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useTheme } from "tamagui";
|
||||
|
||||
import { useAudioTrack } from "~/hooks/player/useAudioTrack";
|
||||
import { useAudioTrackStore } from "~/stores/audio";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Controls } from "./Controls";
|
||||
import { Settings } from "./settings/Sheet";
|
||||
|
||||
export interface AudioTrack {
|
||||
uri: string;
|
||||
name: string;
|
||||
language: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const AudioTrackSelector = () => {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const tracks = usePlayerStore((state) => state.interface.audioTracks);
|
||||
const setAudioTracks = usePlayerStore((state) => state.setAudioTracks);
|
||||
const stream = usePlayerStore((state) => state.interface.currentStream);
|
||||
const selectedTrack = useAudioTrackStore((state) => state.selectedTrack);
|
||||
|
||||
const setSelectedAudioTrack = useAudioTrackStore(
|
||||
(state) => state.setSelectedAudioTrack,
|
||||
);
|
||||
|
||||
const { synchronizePlayback } = useAudioTrack();
|
||||
|
||||
useEffect(() => {
|
||||
if (tracks && selectedTrack) {
|
||||
const needsUpdate = tracks.some(
|
||||
(t) => t.active !== (t.uri === selectedTrack.uri),
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
const updatedTracks = tracks.map((t) => ({
|
||||
...t,
|
||||
active: t.uri === selectedTrack.uri,
|
||||
}));
|
||||
setAudioTracks(updatedTracks);
|
||||
}
|
||||
}
|
||||
}, [selectedTrack, setAudioTracks, tracks]);
|
||||
|
||||
if (!tracks?.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="volume-high"
|
||||
size={24}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
Audio
|
||||
</MWButton>
|
||||
</Controls>
|
||||
|
||||
<Settings.Sheet
|
||||
forceRemoveScrollEnabled={open}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
onPress={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
title="Audio"
|
||||
/>
|
||||
<Settings.Content>
|
||||
{tracks?.map((track) => (
|
||||
<Settings.Item
|
||||
key={track.language}
|
||||
title={track.name}
|
||||
iconRight={
|
||||
track.active && (
|
||||
<MaterialCommunityIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={() => {
|
||||
setSelectedAudioTrack(track);
|
||||
if (stream) {
|
||||
void synchronizePlayback(track, stream);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Settings.Content>
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
44
apps/expo/src/components/player/BackButton.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
95
apps/expo/src/components/player/BottomControls.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { isDevelopmentProvisioningProfile } from "modules/check-ios-certificate";
|
||||
import { Text, View } from "tamagui";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { CaptionsSelector } from "./CaptionsSelector";
|
||||
import { Controls } from "./Controls";
|
||||
import { DownloadButton } from "./DownloadButton";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { SeasonSelector } from "./SeasonEpisodeSelector";
|
||||
import { SettingsSelector } from "./SettingsSelector";
|
||||
import { SourceSelector } from "./SourceSelector";
|
||||
import { mapMillisecondsToTime } from "./utils";
|
||||
|
||||
export const BottomControls = () => {
|
||||
const status = usePlayerStore((state) => state.status);
|
||||
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
|
||||
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
|
||||
const [showRemaining, setShowRemaining] = useState(false);
|
||||
|
||||
const toggleTimeDisplay = useCallback(() => {
|
||||
setIsIdle(false);
|
||||
setShowRemaining(!showRemaining);
|
||||
}, [showRemaining, setIsIdle]);
|
||||
|
||||
const { currentTime, remainingTime } = useMemo(() => {
|
||||
if (status?.isLoaded) {
|
||||
const current = mapMillisecondsToTime(status.positionMillis ?? 0);
|
||||
const remaining = `-${mapMillisecondsToTime(
|
||||
(status.durationMillis ?? 0) - (status.positionMillis ?? 0),
|
||||
)}`;
|
||||
return { currentTime: current, remainingTime: remaining };
|
||||
} else {
|
||||
return { currentTime: "", remainingTime: "" };
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const durationTime = useMemo(() => {
|
||||
if (status?.isLoaded) {
|
||||
return mapMillisecondsToTime(status.durationMillis ?? 0);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
if (status?.isLoaded) {
|
||||
return (
|
||||
<View
|
||||
height={128}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={24}
|
||||
>
|
||||
<Controls>
|
||||
<View flexDirection="row" justifyContent="space-between" width="$11">
|
||||
<Text fontWeight="bold">{currentTime}</Text>
|
||||
<Text marginHorizontal={1} fontWeight="bold">
|
||||
/
|
||||
</Text>
|
||||
<TouchableOpacity onPress={toggleTimeDisplay}>
|
||||
<Text fontWeight="bold">
|
||||
{showRemaining ? remainingTime : durationTime}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ProgressBar />
|
||||
</Controls>
|
||||
<View
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
paddingBottom={40}
|
||||
>
|
||||
{!isLocalFile && (
|
||||
<>
|
||||
<SeasonSelector />
|
||||
<CaptionsSelector />
|
||||
<SourceSelector />
|
||||
<AudioTrackSelector />
|
||||
<SettingsSelector />
|
||||
{Platform.OS === "android" ||
|
||||
(Platform.OS === "ios" && isDevelopmentProvisioningProfile()) ? (
|
||||
<DownloadButton />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
95
apps/expo/src/components/player/CaptionRenderer.tsx
Normal 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>
|
||||
);
|
||||
};
|
174
apps/expo/src/components/player/CaptionsSelector.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { LanguageCode } from "iso-639-1";
|
||||
import type { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
import { useState } from "react";
|
||||
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { parse } from "subsrt-ts";
|
||||
import { Spinner, useTheme, View } from "tamagui";
|
||||
|
||||
import type { Stream } from "@movie-web/provider-utils";
|
||||
|
||||
import type { CaptionWithData } from "~/stores/captions";
|
||||
import { useToast } from "~/hooks/useToast";
|
||||
import {
|
||||
getCountryCodeFromLanguage,
|
||||
getPrettyLanguageNameFromLocale,
|
||||
} from "~/lib/language";
|
||||
import { useCaptionsStore } from "~/stores/captions";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { FlagIcon } from "../FlagIcon";
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Controls } from "./Controls";
|
||||
import { Settings } from "./settings/Sheet";
|
||||
|
||||
const parseCaption = async (
|
||||
caption: Stream["captions"][0],
|
||||
): Promise<CaptionWithData> => {
|
||||
const response = await fetch(caption.url);
|
||||
const data = await response.text();
|
||||
return {
|
||||
...caption,
|
||||
data: parse(data).filter(
|
||||
(cue) => cue.type === "caption",
|
||||
) as ContentCaption[],
|
||||
};
|
||||
};
|
||||
|
||||
export const CaptionsSelector = () => {
|
||||
const { showToast } = useToast();
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const captions = usePlayerStore(
|
||||
(state) => state.interface.currentStream?.captions,
|
||||
);
|
||||
const selectedCaption = useCaptionsStore((state) => state.selectedCaption);
|
||||
const setSelectedCaption = useCaptionsStore(
|
||||
(state) => state.setSelectedCaption,
|
||||
);
|
||||
|
||||
const downloadCaption = useMutation({
|
||||
mutationKey: ["captions", selectedCaption?.id],
|
||||
mutationFn: parseCaption,
|
||||
onSuccess: (data) => {
|
||||
setSelectedCaption(data);
|
||||
},
|
||||
});
|
||||
|
||||
if (!captions?.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="subtitles"
|
||||
size={24}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
Subtitles
|
||||
</MWButton>
|
||||
</Controls>
|
||||
<Settings.Sheet
|
||||
forceRemoveScrollEnabled={open}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<MaterialIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
onPress={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
title="Subtitles"
|
||||
rightButton={
|
||||
<MWButton
|
||||
color="$playerSettingsUnactiveText"
|
||||
fontWeight="bold"
|
||||
chromeless
|
||||
onPress={() => {
|
||||
showToast("Work in progress");
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</MWButton>
|
||||
}
|
||||
/>
|
||||
<Settings.Content>
|
||||
<Settings.Item
|
||||
iconLeft={
|
||||
<View
|
||||
width="$5"
|
||||
height="$3"
|
||||
backgroundColor="$subtitleSelectorBackground"
|
||||
borderRadius="$5"
|
||||
/>
|
||||
}
|
||||
title={"Off"}
|
||||
iconRight={
|
||||
<>
|
||||
{!selectedCaption?.id && (
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.sheetItemSelected.val}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onPress={() => setSelectedCaption(null)}
|
||||
/>
|
||||
|
||||
{captions?.map((caption) => (
|
||||
<Settings.Item
|
||||
iconLeft={
|
||||
<View
|
||||
width="$5"
|
||||
height="$3"
|
||||
backgroundColor="$subtitleSelectorBackground"
|
||||
borderRadius="$5"
|
||||
overflow="hidden"
|
||||
>
|
||||
<FlagIcon
|
||||
languageCode={getCountryCodeFromLanguage(
|
||||
caption.language as LanguageCode,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={getPrettyLanguageNameFromLocale(caption.language) ?? ""}
|
||||
iconRight={
|
||||
<>
|
||||
{selectedCaption?.id === caption.id && (
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.sheetItemSelected.val}
|
||||
/>
|
||||
)}
|
||||
{downloadCaption.isPending &&
|
||||
downloadCaption.variables.id === caption.id && (
|
||||
<Spinner size="small" color="$loadingIndicator" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onPress={() => downloadCaption.mutate(caption)}
|
||||
key={caption.id}
|
||||
/>
|
||||
))}
|
||||
</Settings.Content>
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
14
apps/expo/src/components/player/Controls.tsx
Normal 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>;
|
||||
};
|
20
apps/expo/src/components/player/ControlsOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
58
apps/expo/src/components/player/DownloadButton.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useTheme } from "tamagui";
|
||||
|
||||
import { findQuality } from "@movie-web/provider-utils";
|
||||
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Controls } from "./Controls";
|
||||
|
||||
export const DownloadButton = () => {
|
||||
const theme = useTheme();
|
||||
const { startDownload } = useDownloadManager();
|
||||
const stream = usePlayerStore((state) => state.interface.currentStream);
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
|
||||
if (!meta) return null;
|
||||
|
||||
const scrapeMedia = convertMetaToScrapeMedia(meta);
|
||||
let url: string | undefined | null = null;
|
||||
|
||||
if (stream?.type === "file") {
|
||||
const highestQuality = findQuality(stream);
|
||||
url = highestQuality ? stream.qualities[highestQuality]?.url : null;
|
||||
} else if (stream?.type === "hls") {
|
||||
url = stream.playlist;
|
||||
}
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() =>
|
||||
url &&
|
||||
startDownload(
|
||||
url,
|
||||
stream?.type === "hls" ? "hls" : "mp4",
|
||||
scrapeMedia,
|
||||
).catch(console.error)
|
||||
}
|
||||
>
|
||||
Download
|
||||
</MWButton>
|
||||
</Controls>
|
||||
</>
|
||||
);
|
||||
};
|
45
apps/expo/src/components/player/Header.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Text, View } from "tamagui";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { BrandPill } from "../BrandPill";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { Controls } from "./Controls";
|
||||
import { mapSeasonAndEpisodeNumberToText } from "./utils";
|
||||
|
||||
export const Header = () => {
|
||||
const isIdle = usePlayerStore((state) => state.interface.isIdle);
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
|
||||
if (!isIdle) {
|
||||
return (
|
||||
<View
|
||||
zIndex={50}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
height={64}
|
||||
paddingHorizontal="$8"
|
||||
>
|
||||
<View width={150}>
|
||||
<Controls>
|
||||
<BackButton />
|
||||
</Controls>
|
||||
</View>
|
||||
{meta && (
|
||||
<Text fontWeight="bold">
|
||||
{meta.title} ({meta.releaseYear}){" "}
|
||||
{meta.season !== undefined && meta.episode !== undefined
|
||||
? mapSeasonAndEpisodeNumberToText(
|
||||
meta.season.number,
|
||||
meta.episode.number,
|
||||
)
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
<View alignItems="center" justifyContent="center" width={150}>
|
||||
<BrandPill />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
41
apps/expo/src/components/player/MiddleControls.tsx
Normal 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>
|
||||
);
|
||||
};
|
33
apps/expo/src/components/player/PlayButton.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
59
apps/expo/src/components/player/PlaybackSpeedSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
37
apps/expo/src/components/player/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
};
|
93
apps/expo/src/components/player/QualitySelector.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
115
apps/expo/src/components/player/ScrapeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
214
apps/expo/src/components/player/ScraperProcess.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { SafeAreaView } from "react-native";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { useRouter } from "expo-router";
|
||||
import { View } from "tamagui";
|
||||
|
||||
import type { RunOutput, ScrapeMedia } from "@movie-web/provider-utils";
|
||||
import {
|
||||
extractTracksFromHLS,
|
||||
filterAudioTracks,
|
||||
findQuality,
|
||||
} from "@movie-web/provider-utils";
|
||||
|
||||
import type { ItemData } from "../item/item";
|
||||
import type { PlayerMeta } from "~/stores/player/slices/video";
|
||||
import { useMeta } from "~/hooks/player/useMeta";
|
||||
import { useScrape } from "~/hooks/player/useSourceScrape";
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { ScrapeCard, ScrapeItem } from "./ScrapeCard";
|
||||
|
||||
interface ScraperProcessProps {
|
||||
data?: Partial<ItemData>;
|
||||
media?: ScrapeMedia;
|
||||
download?: boolean;
|
||||
}
|
||||
|
||||
export const ScraperProcess = ({
|
||||
data,
|
||||
media,
|
||||
download,
|
||||
}: ScraperProcessProps) => {
|
||||
const router = useRouter();
|
||||
const { startDownload } = useDownloadManager();
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const { convertIdToMeta } = useMeta();
|
||||
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
|
||||
|
||||
const setStream = usePlayerStore((state) => state.setCurrentStream);
|
||||
const setHlsTracks = usePlayerStore((state) => state.setHlsTracks);
|
||||
const setAudioTracks = usePlayerStore((state) => state.setAudioTracks);
|
||||
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
|
||||
const setSourceId = usePlayerStore((state) => state.setSourceId);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!data?.id && !media) return router.back();
|
||||
|
||||
let streamResult: RunOutput | null = null;
|
||||
let meta: PlayerMeta | undefined = undefined;
|
||||
|
||||
if (!media && data?.id && data.type) {
|
||||
meta = await convertIdToMeta(
|
||||
data.id,
|
||||
data.type,
|
||||
data.season,
|
||||
data.episode,
|
||||
);
|
||||
if (!meta) return router.back();
|
||||
}
|
||||
|
||||
const scrapeMedia = media ?? (meta && convertMetaToScrapeMedia(meta));
|
||||
if (!scrapeMedia) return router.back();
|
||||
streamResult = await startScraping(scrapeMedia);
|
||||
|
||||
if (!streamResult) return router.back();
|
||||
if (download) {
|
||||
if (streamResult.stream.type === "file") {
|
||||
const quality = findQuality(streamResult.stream);
|
||||
const url = quality
|
||||
? streamResult.stream.qualities[quality]?.url
|
||||
: null;
|
||||
if (!url) return;
|
||||
startDownload(url, "mp4", scrapeMedia).catch(console.error);
|
||||
} else if (streamResult.stream.type === "hls") {
|
||||
startDownload(streamResult.stream.playlist, "hls", scrapeMedia).catch(
|
||||
console.error,
|
||||
);
|
||||
}
|
||||
return router.back();
|
||||
}
|
||||
|
||||
setStream(streamResult.stream);
|
||||
|
||||
if (streamResult.stream.type === "hls") {
|
||||
const tracks = await extractTracksFromHLS(
|
||||
streamResult.stream.playlist,
|
||||
{
|
||||
...streamResult.stream.preferredHeaders,
|
||||
...streamResult.stream.headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (tracks) setHlsTracks(tracks);
|
||||
|
||||
if (tracks?.audio.length) {
|
||||
setAudioTracks(
|
||||
filterAudioTracks(tracks, streamResult.stream.playlist),
|
||||
);
|
||||
}
|
||||
}
|
||||
setPlayerStatus(PlayerStatus.READY);
|
||||
setSourceId(streamResult.sourceId);
|
||||
};
|
||||
void fetchData();
|
||||
}, [
|
||||
convertIdToMeta,
|
||||
data,
|
||||
download,
|
||||
media,
|
||||
router,
|
||||
setAudioTracks,
|
||||
setHlsTracks,
|
||||
setPlayerStatus,
|
||||
setSourceId,
|
||||
setStream,
|
||||
startDownload,
|
||||
startScraping,
|
||||
]);
|
||||
|
||||
let currentProviderIndex = sourceOrder.findIndex(
|
||||
(s) => s.id === currentSource || s.children.includes(currentSource ?? ""),
|
||||
);
|
||||
if (currentProviderIndex === -1) {
|
||||
currentProviderIndex = sourceOrder.length - 1;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollTo({
|
||||
y: currentProviderIndex * 110,
|
||||
animated: true,
|
||||
});
|
||||
}, [currentProviderIndex]);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
flex={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="$screenBackground"
|
||||
>
|
||||
<View position="absolute" top={40} left={40}>
|
||||
<BackButton />
|
||||
</View>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 64,
|
||||
}}
|
||||
>
|
||||
{sourceOrder.map((order) => {
|
||||
const source = sources[order.id];
|
||||
if (!source) return null;
|
||||
const distance = Math.abs(
|
||||
sourceOrder.findIndex((o) => o.id === order.id) -
|
||||
currentProviderIndex,
|
||||
);
|
||||
return (
|
||||
<View
|
||||
key={order.id}
|
||||
style={{ opacity: Math.max(0, 1 - distance * 0.3) }}
|
||||
>
|
||||
<ScrapeCard
|
||||
id={order.id}
|
||||
name={source.name}
|
||||
status={source.status}
|
||||
hasChildren={order.children.length > 0}
|
||||
percentage={source.percentage}
|
||||
>
|
||||
<View
|
||||
marginTop={order.children.length > 0 ? 8 : 0}
|
||||
flexDirection="column"
|
||||
gap={16}
|
||||
>
|
||||
{order.children.map((embedId) => {
|
||||
const embed = sources[embedId];
|
||||
if (!embed) return null;
|
||||
return (
|
||||
<ScrapeItem
|
||||
id={embedId}
|
||||
name={embed.name}
|
||||
status={embed.status}
|
||||
percentage={embed.percentage}
|
||||
key={embedId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrapeCard>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
184
apps/expo/src/components/player/SeasonEpisodeSelector.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { SheetProps } from "tamagui";
|
||||
import { useState } from "react";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTheme, View } from "tamagui";
|
||||
|
||||
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Controls } from "./Controls";
|
||||
import { Settings } from "./settings/Sheet";
|
||||
|
||||
const EpisodeSelector = ({
|
||||
seasonNumber,
|
||||
setSelectedSeason,
|
||||
...props
|
||||
}: SheetProps & {
|
||||
seasonNumber: number;
|
||||
setSelectedSeason: (season: number | null) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
const setMeta = usePlayerStore((state) => state.setMeta);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["seasonEpisodes", meta!.tmdbId, seasonNumber],
|
||||
queryFn: async () => {
|
||||
return fetchSeasonDetails(meta!.tmdbId, seasonNumber);
|
||||
},
|
||||
enabled: meta !== null,
|
||||
});
|
||||
|
||||
if (!meta) return null;
|
||||
|
||||
return (
|
||||
<Settings.Sheet
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
{...props}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame isLoading={isLoading}>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<Ionicons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={theme.silver300.val}
|
||||
onPress={() => {
|
||||
setSelectedSeason(null);
|
||||
props.onOpenChange?.(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={`Season ${data?.season_number}`}
|
||||
/>
|
||||
<Settings.Content>
|
||||
{data?.episodes.map((episode) => (
|
||||
<Settings.Item
|
||||
key={episode.id}
|
||||
iconLeft={
|
||||
<View
|
||||
width={32}
|
||||
height={32}
|
||||
backgroundColor="#121c24"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
borderRadius={6}
|
||||
>
|
||||
<Settings.Text fontSize={14}>
|
||||
E{episode.episode_number}
|
||||
</Settings.Text>
|
||||
</View>
|
||||
}
|
||||
title={episode.name}
|
||||
onPress={() => {
|
||||
setMeta({
|
||||
...meta,
|
||||
episode: {
|
||||
number: episode.episode_number,
|
||||
tmdbId: episode.id.toString(),
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Settings.Content>
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export const SeasonSelector = () => {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [episodeOpen, setEpisodeOpen] = useState(false);
|
||||
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["seasons", meta!.tmdbId],
|
||||
queryFn: async () => {
|
||||
return fetchMediaDetails(meta!.tmdbId, "tv");
|
||||
},
|
||||
enabled: meta !== null,
|
||||
});
|
||||
|
||||
if (meta?.type !== "show") return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="audio-video"
|
||||
size={24}
|
||||
color={theme.silver300.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
Episodes
|
||||
</MWButton>
|
||||
</Controls>
|
||||
|
||||
<Settings.Sheet
|
||||
forceRemoveScrollEnabled={open}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame isLoading={isLoading}>
|
||||
{episodeOpen && selectedSeason ? (
|
||||
<EpisodeSelector
|
||||
seasonNumber={selectedSeason}
|
||||
setSelectedSeason={setSelectedSeason}
|
||||
open={episodeOpen}
|
||||
onOpenChange={setEpisodeOpen}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
onPress={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
title={data?.result.name ?? ""}
|
||||
/>
|
||||
<Settings.Content>
|
||||
{data?.result.seasons.map((season) => (
|
||||
<Settings.Item
|
||||
key={season.season_number}
|
||||
title={`Season ${season.season_number}`}
|
||||
iconRight={
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
}
|
||||
onPress={() => {
|
||||
setSelectedSeason(season.season_number);
|
||||
setEpisodeOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Settings.Content>
|
||||
</>
|
||||
)}
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
</>
|
||||
);
|
||||
};
|