Compare commits
425 Commits
dev
...
85ab358a13
Author | SHA1 | Date | |
---|---|---|---|
|
85ab358a13 | ||
|
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 |
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"
|
||||
}
|
2
.github/renovate.json
vendored
@@ -4,7 +4,7 @@
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["^@movie-web/"],
|
||||
"enabled": false
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"updateInternalDeps": true,
|
||||
|
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: 8
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Build Android app
|
||||
run: cd apps/expo && pnpm apk
|
||||
|
||||
- name: Upload movie-web.apk as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: apk
|
||||
path: ./apps/expo/android/app/build/movie-web.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-14
|
||||
if: github.event.issue.pull_request && contains(github.event.comment.body, '/build')
|
||||
steps:
|
||||
- uses: xt0rted/pull-request-comment-branch@v2
|
||||
id: comment-branch
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Cache Pods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/expo/ios
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build iOS app
|
||||
run: cd apps/expo && pnpm ipa
|
||||
|
||||
- name: Upload movie-web.ipa as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ipa
|
||||
path: ./apps/expo/ios/build/movie-web.ipa
|
80
.github/workflows/build-mobile.yml
vendored
@@ -7,8 +7,9 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -16,40 +17,47 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
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: Build Android app
|
||||
run: cd apps/expo && pnpm run apk
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Rename apk
|
||||
run: cd apps/expo && mv android/app/build/outputs/apk/release/app-release.apk android/app/build/outputs/apk/release/movie-web.apk
|
||||
- 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/outputs/apk/release/movie-web.apk
|
||||
path: ./apps/expo/android/app/build/movie-web.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-14
|
||||
@@ -58,35 +66,35 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Xcode Select Version
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '15.1.0'
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Cache Pods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/expo/ios
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build iOS app
|
||||
run: cd apps/expo && pnpm run ipa
|
||||
|
||||
- name: Export .ipa from .app
|
||||
run: |
|
||||
cd apps/expo
|
||||
mkdir -p ios/build/Build/Products/Release-iphoneos/Payload
|
||||
mv ios/build/Build/Products/Release-iphoneos/movieweb.app ios/build/Build/Products/Release-iphoneos/Payload/
|
||||
cd ios/build/Build/Products/Release-iphoneos
|
||||
zip -r ../../../movie-web.ipa Payload
|
||||
run: cd apps/expo && pnpm ipa
|
||||
|
||||
- name: Upload movie-web.ipa as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
148
.github/workflows/release-mobile.yml
vendored
@@ -4,28 +4,30 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
runs-on: ubuntu-latest
|
||||
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"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Automated Version Bump
|
||||
uses: phips28/gh-action-bump-version@v10.1.1
|
||||
with:
|
||||
skip-tag: 'true'
|
||||
commit-message: 'chore: bump mobile version to {{version}} [skip ci]'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump-version]
|
||||
@@ -34,40 +36,50 @@ jobs:
|
||||
- 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: 8
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
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: Build Android app
|
||||
run: cd apps/expo && pnpm run apk
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Rename apk
|
||||
run: cd apps/expo && mv android/app/build/outputs/apk/release/app-release.apk android/app/build/outputs/apk/release/movie-web.apk
|
||||
- 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/outputs/apk/release/movie-web.apk
|
||||
path: ./apps/expo/android/app/build/movie-web.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-14
|
||||
@@ -77,35 +89,38 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Xcode Select Version
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '15.1.0'
|
||||
- name: Pull version bump
|
||||
run: git pull --all
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Cache Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Cache Pods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/expo/ios
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build iOS app
|
||||
run: cd apps/expo && pnpm run ipa
|
||||
|
||||
- name: Export .ipa from .app
|
||||
run: |
|
||||
cd apps/expo
|
||||
mkdir -p ios/build/Build/Products/Release-iphoneos/Payload
|
||||
mv ios/build/Build/Products/Release-iphoneos/movieweb.app ios/build/Build/Products/Release-iphoneos/Payload/
|
||||
cd ios/build/Build/Products/Release-iphoneos
|
||||
zip -r ../../../movie-web.ipa Payload
|
||||
run: cd apps/expo && pnpm ipa
|
||||
|
||||
- name: Upload movie-web.ipa as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -121,6 +136,9 @@ jobs:
|
||||
- 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:
|
||||
@@ -129,15 +147,47 @@ jobs:
|
||||
- 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 }}
|
||||
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"
|
5
.gitignore
vendored
@@ -19,6 +19,8 @@ expo-env.d.ts
|
||||
apps/expo/.gitignore
|
||||
ios/
|
||||
android/
|
||||
!modules/*/ios/
|
||||
!modules/*/android/
|
||||
|
||||
# production
|
||||
build
|
||||
@@ -45,3 +47,6 @@ yarn-error.log*
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# tamagui
|
||||
.tamagui
|
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"
|
||||
},
|
||||
]
|
||||
}
|
8
.vscode/settings.json
vendored
@@ -21,5 +21,11 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
79
README.md
@@ -1,8 +1,32 @@
|
||||
# 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
|
||||
|
||||
It uses [Turborepo](https://turborepo.org) and contains:
|
||||
This repository uses [Turborepo](https://turborepo.org) and contains:
|
||||
|
||||
```text
|
||||
.github
|
||||
@@ -15,56 +39,37 @@ apps
|
||||
├─ Expo SDK 50
|
||||
├─ React Native using React 18
|
||||
├─ Navigation using Expo Router
|
||||
├─ Tailwind using Nativewind
|
||||
└─ Typesafe API calls using tRPC
|
||||
└─ Styling with Tamagui
|
||||
packages
|
||||
├─ api
|
||||
| └─ Typesafe API calls to the backend
|
||||
├─ tmdb
|
||||
└─ Typesafe API calls to The Movie Database
|
||||
| └─ Typesafe API calls to The Movie Database
|
||||
└─ provider-utils
|
||||
└─ Typesafe API calls to the video providers
|
||||
tooling
|
||||
├─ color
|
||||
| └─ shared color palette
|
||||
├─ eslint
|
||||
| └─ shared, fine-grained, eslint presets
|
||||
├─ prettier
|
||||
| └─ shared prettier configuration
|
||||
├─ tailwind
|
||||
| └─ shared tailwind configuration
|
||||
└─ typescript
|
||||
└─ shared tsconfig you can extend from
|
||||
```
|
||||
|
||||
### Configure Expo `dev`-script
|
||||
## Getting started
|
||||
|
||||
#### Use iOS Simulator
|
||||
### When it's time to add a new package
|
||||
|
||||
1. Make sure you have XCode and XCommand Line Tools installed [as shown on expo docs](https://docs.expo.dev/workflow/ios-simulator).
|
||||
To add a new package, simply run `pnpm turbo gen init` in the monorepo root. This will prompt you for a package name as
|
||||
well as if you want to install any dependencies to the new package (of course you can also do this yourself later).
|
||||
|
||||
> **NOTE:** If you just installed XCode, or if you have updated it, you need to open the simulator manually once. Run `npx expo start` in the root dir, and then enter `I` to launch Expo Go. After the manual launch, you can run `pnpm dev` in the root directory.
|
||||
|
||||
```diff
|
||||
+ "dev": "expo start --ios",
|
||||
```
|
||||
|
||||
2. Run `pnpm dev` at the project root folder.
|
||||
|
||||
#### Use Android Emulator
|
||||
|
||||
1. Install Android Studio tools [as shown on expo docs](https://docs.expo.dev/workflow/android-studio-emulator).
|
||||
|
||||
2. Change the `dev` script at `apps/expo/package.json` to open the Android emulator.
|
||||
|
||||
```diff
|
||||
+ "dev": "expo start --android",
|
||||
```
|
||||
|
||||
3. Run `pnpm dev` at the project root folder.
|
||||
|
||||
> **TIP:** It might be easier to run each app in separate terminal windows so you get the logs from each app separately. This is also required if you want your terminals to be interactive, e.g. to access the Expo QR code. You can run `pnpm --filter expo dev` and `pnpm --filter nextjs dev` to run each app in a separate terminal window.
|
||||
|
||||
### 3. When it's time to add a new package
|
||||
|
||||
To add a new package, simply run `pnpm turbo gen init` in the monorepo root. This will prompt you for a package name as well as if you want to install any dependencies to the new package (of course you can also do this yourself later).
|
||||
|
||||
The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary configurations for tooling around your package such as formatting, linting and typechecking. When the package is created, you're ready to go build out the package.
|
||||
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).
|
||||
This app is based on [create-t3-turbo](https://github.com/t3-oss/create-t3-turbo)
|
||||
and [Turborepo](https://turborepo.org).
|
||||
|
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": []
|
||||
}
|
@@ -1,17 +1,20 @@
|
||||
import type { ExpoConfig } from "expo/config";
|
||||
|
||||
import { version } from "./package.json";
|
||||
import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement";
|
||||
import withRNBackgroundDownloader from "./src/plugins/withRNBackgroundDownloader";
|
||||
|
||||
const defineConfig = (): ExpoConfig => ({
|
||||
name: "movie-web",
|
||||
slug: "mw-mobile",
|
||||
scheme: "dev.movieweb.app",
|
||||
version: "0.1.0",
|
||||
orientation: "portrait",
|
||||
scheme: "movieweb",
|
||||
version,
|
||||
icon: "./assets/images/icon.png",
|
||||
userInterfaceStyle: "automatic",
|
||||
splash: {
|
||||
image: "./assets/images/splash.png",
|
||||
resizeMode: "contain",
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundColor: "#000000",
|
||||
},
|
||||
updates: {
|
||||
fallbackToCacheTimeout: 0,
|
||||
@@ -20,28 +23,73 @@ const defineConfig = (): ExpoConfig => ({
|
||||
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",
|
||||
adaptiveIcon: {
|
||||
foregroundImage: "./assets/images/adaptive-icon.png",
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
permissions: ["WRITE_SETTINGS"],
|
||||
},
|
||||
web: {
|
||||
favicon: "./assets/images/favicon.png",
|
||||
bundler: "metro",
|
||||
},
|
||||
// extra: {
|
||||
// eas: {
|
||||
// projectId: "your-eas-project-id",
|
||||
// },
|
||||
// },
|
||||
experiments: {
|
||||
tsconfigPaths: true,
|
||||
typedRoutes: true,
|
||||
},
|
||||
plugins: ["expo-router"],
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[withRemoveiOSNotificationEntitlement as unknown as string],
|
||||
[withRNBackgroundDownloader as unknown as string],
|
||||
[
|
||||
"expo-screen-orientation",
|
||||
{
|
||||
initialOrientation: "PORTRAIT_UP",
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
android: {
|
||||
minSdkVersion: 24,
|
||||
packagingOptions: {
|
||||
pickFirst: [
|
||||
"lib/x86/libcrypto.so",
|
||||
"lib/x86_64/libcrypto.so",
|
||||
"lib/armeabi-v7a/libcrypto.so",
|
||||
"lib/arm64-v8a/libcrypto.so",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-alternate-app-icons",
|
||||
[
|
||||
"./assets/images/main.png",
|
||||
"./assets/images/blue.png",
|
||||
"./assets/images/gray.png",
|
||||
"./assets/images/red.png",
|
||||
"./assets/images/teal.png",
|
||||
],
|
||||
],
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
||||
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
|
||||
isAccessMediaLocationEnabled: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
export default defineConfig;
|
||||
|
Before Width: | Height: | Size: 17 KiB |
BIN
apps/expo/assets/images/blue.png
Normal file
After Width: | Height: | Size: 5.8 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 721 B |
BIN
apps/expo/assets/images/gray.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 22 KiB 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 |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 244 KiB |
BIN
apps/expo/assets/images/teal.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
@@ -2,10 +2,20 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
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",
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: ["react-native-reanimated/plugin"],
|
||||
};
|
||||
};
|
||||
|
3
apps/expo/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import "expo-router/entry";
|
||||
import "react-native-gesture-handler";
|
||||
import "@react-native-anywhere/polyfill-base64";
|
@@ -1,19 +1,19 @@
|
||||
// Learn more: https://docs.expo.dev/guides/monorepos/
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { FileStore } = require("metro-cache");
|
||||
const { withNativeWind } = require("nativewind/metro");
|
||||
const { withTamagui } = require("@tamagui/metro-plugin");
|
||||
|
||||
const path = require("path");
|
||||
|
||||
module.exports = withTurborepoManagedCache(
|
||||
withMonorepoPaths(
|
||||
withNativeWind(
|
||||
withTamagui(
|
||||
getDefaultConfig(__dirname, {
|
||||
isCSSEnabled: true,
|
||||
}),
|
||||
{
|
||||
input: "./src/app/styles/global.css",
|
||||
configPath: "./tailwind.config.ts",
|
||||
components: ["tamagui"],
|
||||
config: "./tamagui.config.ts",
|
||||
},
|
||||
),
|
||||
),
|
||||
|
@@ -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");
|
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@movie-web/mobile",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .expo .turbo node_modules",
|
||||
"dev": "expo start",
|
||||
@@ -10,36 +10,73 @@
|
||||
"dev:ios": "expo start -c --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"apk": "expo prebuild --platform=android && cd android && ./gradlew assembleRelease",
|
||||
"ipa": "expo prebuild --platform=ios && cd ios && xcodebuild -workspace movieweb.xcworkspace -scheme movieweb -sdk iphoneos -configuration Release -derivedDataPath build -destination generic/platform=iOS CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO",
|
||||
"ipa:sim": "expo prebuild --platform=ios && cd ios && xcodebuild -workspace movieweb.xcworkspace -scheme movieweb -sdk iphonesimulator -configuration Release -derivedDataPath build -destination \"generic/platform=iOS Simulator\" CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO",
|
||||
"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",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.1.2",
|
||||
"@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",
|
||||
"clsx": "^2.1.0",
|
||||
"expo": "~50.0.5",
|
||||
"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-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-router": "~3.4.6",
|
||||
"expo-media-library": "~15.9.1",
|
||||
"expo-navigation-bar": "^2.8.1",
|
||||
"expo-network": "~5.8.0",
|
||||
"expo-router": "~3.4.8",
|
||||
"expo-screen-orientation": "~6.4.1",
|
||||
"expo-splash-screen": "~0.26.4",
|
||||
"expo-status-bar": "~1.11.1",
|
||||
"expo-system-ui": "^2.9.3",
|
||||
"expo-web-browser": "^12.8.2",
|
||||
"nativewind": "~4.0.23",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"immer": "^10.0.3",
|
||||
"iso-639-1": "^3.1.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.73.2",
|
||||
"react-native-css-interop": "~0.0.22",
|
||||
"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",
|
||||
"tailwind-merge": "^2.2.1"
|
||||
"subsrt-ts": "^2.1.2",
|
||||
"tamagui": "^1.94.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.9",
|
||||
@@ -47,14 +84,14 @@
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@movie-web/eslint-config": "workspace:^0.2.0",
|
||||
"@movie-web/prettier-config": "workspace:^0.1.0",
|
||||
"@movie-web/tailwind-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",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.4.3"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
|
@@ -1,24 +1,39 @@
|
||||
import { View } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Tabs } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useTheme, View } from "tamagui";
|
||||
|
||||
import Colors from "@movie-web/tailwind-config/colors";
|
||||
|
||||
import { MovieWebSvg } from "~/components/Icon";
|
||||
import SvgTabBarIcon from "~/components/SvgTabBarIcon";
|
||||
import TabBarIcon from "~/components/TabBarIcon";
|
||||
|
||||
export default function TabLayout() {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Tabs
|
||||
sceneContainerStyle={{
|
||||
backgroundColor: Colors.background,
|
||||
backgroundColor: theme.screenBackground.val,
|
||||
}}
|
||||
screenListeners={() => ({
|
||||
tabPress: () => {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
},
|
||||
focus: () => {
|
||||
void ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
},
|
||||
})}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: Colors.primary[100],
|
||||
tabBarActiveTintColor: theme.tabBarIconFocused.val,
|
||||
tabBarStyle: {
|
||||
backgroundColor: Colors.secondary[700],
|
||||
backgroundColor: theme.tabBarBackground.val,
|
||||
borderTopColor: "transparent",
|
||||
borderTopRightRadius: 20,
|
||||
borderTopLeftRadius: 20,
|
||||
paddingBottom: Platform.select({ ios: 100 }),
|
||||
height: 80,
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
@@ -42,11 +57,11 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="about"
|
||||
name="downloads"
|
||||
options={{
|
||||
title: "About",
|
||||
title: "Downloads",
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabBarIcon name="info-circle" focused={focused} />
|
||||
<TabBarIcon name="download" focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -55,13 +70,35 @@ export default function TabLayout() {
|
||||
options={{
|
||||
title: "Search",
|
||||
tabBarLabel: "",
|
||||
tabBarIcon: () => (
|
||||
<View className="android:top-2 ios:top-2 flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-primary-400 text-center align-middle text-2xl text-white">
|
||||
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={{
|
||||
@@ -71,15 +108,6 @@ export default function TabLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="account"
|
||||
options={{
|
||||
title: "Account",
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabBarIcon name="user" focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
@@ -1,16 +0,0 @@
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { Text } from "~/components/ui/Text";
|
||||
|
||||
export default function AboutScreen() {
|
||||
return (
|
||||
<ScreenLayout
|
||||
title="About"
|
||||
subtitle="What is movie-web and how content is served?"
|
||||
>
|
||||
<Text>
|
||||
No content is served from movie-web directly and movie web does not host
|
||||
anything.
|
||||
</Text>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
@@ -1,13 +0,0 @@
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { Text } from "~/components/ui/Text";
|
||||
|
||||
export default function AccountScreen() {
|
||||
return (
|
||||
<ScreenLayout
|
||||
title="Account"
|
||||
subtitle="Manage your movie web account from here"
|
||||
>
|
||||
<Text>Hey Bro! what are you up to?</Text>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
142
apps/expo/src/app/(tabs)/downloads.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import { Alert, Platform } from "react-native";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { isDevelopmentProvisioningProfile } from "modules/check-ios-certificate";
|
||||
import { ScrollView, useTheme, YStack } from "tamagui";
|
||||
|
||||
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
||||
|
||||
import { DownloadItem } from "~/components/DownloadItem";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { useDownloadHistoryStore } from "~/stores/settings";
|
||||
|
||||
const DownloadsScreen: React.FC = () => {
|
||||
const { startDownload } = useDownloadManager();
|
||||
const downloads = useDownloadHistoryStore((state) => state.downloads);
|
||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
|
||||
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
|
||||
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
if (Platform.OS === "ios" && !isDevelopmentProvisioningProfile()) {
|
||||
Alert.alert(
|
||||
"Production Certificate",
|
||||
"Download functionality is not available when the application is signed with a distribution certificate.",
|
||||
[
|
||||
{
|
||||
text: "OK",
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}, [router]),
|
||||
);
|
||||
|
||||
const handlePress = (localPath?: string) => {
|
||||
if (!localPath) return;
|
||||
resetVideo();
|
||||
setIsLocalFile(true);
|
||||
setPlayerStatus(PlayerStatus.READY);
|
||||
setVideoSrc({
|
||||
uri: localPath,
|
||||
});
|
||||
router.push({
|
||||
pathname: "/videoPlayer",
|
||||
});
|
||||
};
|
||||
|
||||
const exampleShowMedia: ScrapeMedia = {
|
||||
type: "show",
|
||||
title: "Example Show Title",
|
||||
releaseYear: 2022,
|
||||
imdbId: "tt1234567",
|
||||
tmdbId: "12345",
|
||||
season: {
|
||||
number: 1,
|
||||
tmdbId: "54321",
|
||||
},
|
||||
episode: {
|
||||
number: 3,
|
||||
tmdbId: "98765",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
<YStack gap={2} style={{ padding: 10 }}>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={async () => {
|
||||
await startDownload(
|
||||
"https://samplelib.com/lib/preview/mp4/sample-5s.mp4",
|
||||
"mp4",
|
||||
exampleShowMedia,
|
||||
).catch(console.error);
|
||||
}}
|
||||
>
|
||||
test download (mp4)
|
||||
</MWButton>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={async () => {
|
||||
await startDownload(
|
||||
"http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8",
|
||||
"hls",
|
||||
{
|
||||
...exampleShowMedia,
|
||||
tmdbId: "123456",
|
||||
},
|
||||
).catch(console.error);
|
||||
}}
|
||||
>
|
||||
test download (hls)
|
||||
</MWButton>
|
||||
</YStack>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
gap: "$4",
|
||||
}}
|
||||
>
|
||||
{/* TODO: Differentiate movies/shows, shows in new page */}
|
||||
{downloads
|
||||
.map((item) => item.downloads)
|
||||
.flat()
|
||||
.map((item) => (
|
||||
<DownloadItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onPress={() => handlePress(item.localPath)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</ScreenLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadsScreen;
|
@@ -1,10 +1,23 @@
|
||||
import React from "react";
|
||||
import { View } from "tamagui";
|
||||
|
||||
import { ItemListSection } from "~/components/item/ItemListSection";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { Text } from "~/components/ui/Text";
|
||||
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { bookmarks } = useBookmarkStore();
|
||||
const { watchHistory } = useWatchHistoryStore();
|
||||
|
||||
return (
|
||||
<ScreenLayout title="Home" subtitle="This is where all magic happens">
|
||||
<Text>Movies will be listed here</Text>
|
||||
</ScreenLayout>
|
||||
<View style={{ flex: 1 }} flex={1}>
|
||||
<ScreenLayout>
|
||||
<ItemListSection title="Bookmarks" items={bookmarks} />
|
||||
<ItemListSection
|
||||
title="Continue Watching"
|
||||
items={watchHistory.map((x) => x.item)}
|
||||
/>
|
||||
</ScreenLayout>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
5
apps/expo/src/app/(tabs)/movie-web.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
|
||||
export default function MovieWebScreen() {
|
||||
return <ScreenLayout></ScreenLayout>;
|
||||
}
|
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 - 110), // determines the height of the Searchbar above keyboard, use Platform.select to adjust value if needed
|
||||
{
|
||||
duration: e.duration ?? 250, // duration always returns 0 on Android, adjust value if needed
|
||||
easing: Easing.out(Easing.ease),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const keyboardWillHideListener = Keyboard.addListener(
|
||||
"keyboardWillHide",
|
||||
() => {
|
||||
translateY.value = withTiming(0, {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.ease),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
keyboardWillShowListener.remove();
|
||||
keyboardWillHideListener.remove();
|
||||
};
|
||||
}, [translateY]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: fadeAnim.value,
|
||||
};
|
||||
});
|
||||
|
||||
const searchResultsStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: searchResultsOpacity.value,
|
||||
transform: [{ scale: searchResultsScale.value }],
|
||||
};
|
||||
});
|
||||
|
||||
const handleScrollBegin = () => {
|
||||
fadeAnim.value = withTiming(0, {
|
||||
duration: 100,
|
||||
});
|
||||
};
|
||||
|
||||
const handleScrollEnd = () => {
|
||||
fadeAnim.value = withTiming(1, {
|
||||
duration: 100,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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,
|
||||
}));
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { FontAwesome5 } from "@expo/vector-icons";
|
||||
|
||||
import Colors from "@movie-web/tailwind-config/colors";
|
||||
|
||||
export default function Searchbar({
|
||||
onSearchChange,
|
||||
}: {
|
||||
onSearchChange: (text: string) => void;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// When the screen is focused
|
||||
const focus = () => {
|
||||
setTimeout(() => {
|
||||
inputRef?.current?.focus();
|
||||
}, 20);
|
||||
};
|
||||
focus();
|
||||
return focus; // cleanup
|
||||
}, []),
|
||||
);
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setKeyword(text);
|
||||
onSearchChange(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="mb-6 mt-4 flex-row items-center rounded-full border border-primary-400 focus-within:border-primary-300">
|
||||
<View className="ml-1 w-12 items-center justify-center">
|
||||
<FontAwesome5 name="search" size={18} color={Colors.secondary[200]} />
|
||||
</View>
|
||||
<TextInput
|
||||
value={keyword}
|
||||
onChangeText={handleChange}
|
||||
ref={inputRef}
|
||||
placeholder="What are you looking for?"
|
||||
placeholderTextColor={Colors.secondary[200]}
|
||||
className="w-full rounded-3xl py-3 pr-5 text-white focus-visible:outline-none"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
@@ -1,59 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
|
||||
import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
|
||||
|
||||
import type { ItemData } from "~/components/item/item";
|
||||
import Item from "~/components/item/item";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { Text } from "~/components/ui/Text";
|
||||
import Searchbar from "./Searchbar";
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [searchResults, setSearchResults] = useState<ItemData[]>([]);
|
||||
|
||||
const handleSearchChange = async (query: string) => {
|
||||
if (query.length > 0) {
|
||||
const results = await fetchSearchResults(query).catch(() => []);
|
||||
setSearchResults(results);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<ScreenLayout
|
||||
title={
|
||||
<View className="flex-row items-center">
|
||||
<Text className="text-2xl font-bold">Search</Text>
|
||||
</View>
|
||||
}
|
||||
subtitle="Looking for something?"
|
||||
>
|
||||
<Searchbar onSearchChange={handleSearchChange} />
|
||||
<View className="flex w-full flex-1 flex-row flex-wrap justify-start">
|
||||
{searchResults.map((item, index) => (
|
||||
<View key={index} className="basis-1/2 px-3 pb-3">
|
||||
<Item data={item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScreenLayout>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchSearchResults(query: string): Promise<ItemData[]> {
|
||||
const results = await searchTitle(query);
|
||||
|
||||
return results.map((result) => ({
|
||||
id: result.id.toString(),
|
||||
title: result.media_type === "tv" ? result.name : result.title,
|
||||
posterUrl: getMediaPoster(result.poster_path),
|
||||
year: new Date(
|
||||
result.media_type === "tv" ? result.first_air_date : result.release_date,
|
||||
).getFullYear(),
|
||||
type: result.media_type,
|
||||
}));
|
||||
}
|
@@ -1,10 +1,525 @@
|
||||
import type { SelectProps } from "tamagui";
|
||||
import React, { useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import * as Application from "expo-application";
|
||||
import * as Brightness from "expo-brightness";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import {
|
||||
FontAwesome,
|
||||
MaterialCommunityIcons,
|
||||
MaterialIcons,
|
||||
} from "@expo/vector-icons";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Adapt,
|
||||
ScrollView,
|
||||
Select,
|
||||
Sheet,
|
||||
Spinner,
|
||||
Text,
|
||||
useTheme,
|
||||
View,
|
||||
XStack,
|
||||
YStack,
|
||||
} from "tamagui";
|
||||
|
||||
import type { ThemeStoreOption } from "~/stores/theme";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
import { Text } from "~/components/ui/Text";
|
||||
import { MWButton } from "~/components/ui/Button";
|
||||
import { MWSelect } from "~/components/ui/Select";
|
||||
import { MWSeparator } from "~/components/ui/Separator";
|
||||
import { MWSwitch } from "~/components/ui/Switch";
|
||||
import { useToast } from "~/hooks/useToast";
|
||||
import { checkForUpdate } from "~/lib/update";
|
||||
import {
|
||||
useNetworkSettingsStore,
|
||||
usePlayerSettingsStore,
|
||||
} from "~/stores/settings";
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
const themeOptions: ThemeStoreOption[] = [
|
||||
"main",
|
||||
"blue",
|
||||
"gray",
|
||||
"red",
|
||||
"teal",
|
||||
];
|
||||
|
||||
const defaultQualityOptions = ["Highest", "Lowest"];
|
||||
|
||||
export default function SettingsScreen() {
|
||||
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 title="Settings" subtitle="Need to change something?">
|
||||
<Text>Settings would be listed in here. Coming soon</Text>
|
||||
<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.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
iconAfter={
|
||||
<>{mutation.isPending && <Spinner color="$purple200" />}</>
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
onPress={() => mutation.mutate()}
|
||||
>
|
||||
Update
|
||||
</MWButton>
|
||||
</XStack>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<Text fontWeight="$semibold" flexGrow={1}>
|
||||
Storage
|
||||
</Text>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="broom"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => clearCacheDirectory()}
|
||||
>
|
||||
Clear Cache
|
||||
</MWButton>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</View>
|
||||
<UpdateSheet
|
||||
markdownContent={updateMarkdownContent}
|
||||
open={showUpdateSheet}
|
||||
setShowUpdateSheet={setShowUpdateSheet}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateSheet({
|
||||
markdownContent,
|
||||
open,
|
||||
setShowUpdateSheet,
|
||||
downloadUrl,
|
||||
}: {
|
||||
markdownContent: string;
|
||||
open: boolean;
|
||||
setShowUpdateSheet: (value: boolean) => void;
|
||||
downloadUrl: string;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
modal
|
||||
open={open}
|
||||
onOpenChange={setShowUpdateSheet}
|
||||
dismissOnSnapToBottom
|
||||
dismissOnOverlayPress
|
||||
animationConfig={{
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
mass: 1.2,
|
||||
stiffness: 250,
|
||||
}}
|
||||
snapPoints={[35]}
|
||||
>
|
||||
<Sheet.Handle backgroundColor="$sheetHandle" />
|
||||
<Sheet.Frame
|
||||
backgroundColor="$sheetBackground"
|
||||
padding="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<ScrollView>
|
||||
<Markdown
|
||||
style={{
|
||||
text: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdownContent}
|
||||
</Markdown>
|
||||
</ScrollView>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name={Platform.select({ ios: "apple", android: "android" })}
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => WebBrowser.openBrowserAsync(downloadUrl)}
|
||||
>
|
||||
Download
|
||||
</MWButton>
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeSelector(props: SelectProps) {
|
||||
const theme = useTheme();
|
||||
const themeStore = useThemeStore((s) => s.theme);
|
||||
const setTheme = useThemeStore((s) => s.setTheme);
|
||||
|
||||
return (
|
||||
<MWSelect
|
||||
value={themeStore}
|
||||
onValueChange={setTheme}
|
||||
disablePreventBodyScroll
|
||||
{...props}
|
||||
>
|
||||
<MWSelect.Trigger
|
||||
maxWidth="$12"
|
||||
iconAfter={
|
||||
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
|
||||
}
|
||||
>
|
||||
<Select.Value fontWeight="$semibold" textTransform="capitalize" />
|
||||
</MWSelect.Trigger>
|
||||
|
||||
<Adapt platform="native">
|
||||
<Sheet
|
||||
modal
|
||||
dismissOnSnapToBottom
|
||||
dismissOnOverlayPress
|
||||
animationConfig={{
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
mass: 1.2,
|
||||
stiffness: 250,
|
||||
}}
|
||||
snapPoints={[35]}
|
||||
>
|
||||
<Sheet.Handle backgroundColor="$sheetHandle" />
|
||||
<Sheet.Frame
|
||||
backgroundColor="$sheetBackground"
|
||||
padding="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Adapt.Contents />
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
</Sheet>
|
||||
</Adapt>
|
||||
|
||||
<Select.Content>
|
||||
<Select.Viewport
|
||||
animation="static"
|
||||
animateOnly={["transform", "opacity"]}
|
||||
enterStyle={{ o: 0, y: -10 }}
|
||||
exitStyle={{ o: 0, y: 10 }}
|
||||
>
|
||||
{themeOptions.map((item, i) => (
|
||||
<Select.Item
|
||||
index={i}
|
||||
key={item}
|
||||
value={item}
|
||||
backgroundColor="$sheetItemBackground"
|
||||
borderTopRightRadius={i === 0 ? "$8" : 0}
|
||||
borderTopLeftRadius={i === 0 ? "$8" : 0}
|
||||
borderBottomRightRadius={i === themeOptions.length - 1 ? "$8" : 0}
|
||||
borderBottomLeftRadius={i === themeOptions.length - 1 ? "$8" : 0}
|
||||
>
|
||||
<Select.ItemText
|
||||
textTransform="capitalize"
|
||||
fontWeight="$semibold"
|
||||
>
|
||||
{item}
|
||||
</Select.ItemText>
|
||||
<Select.ItemIndicator ml="auto">
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.sheetItemSelected.val}
|
||||
/>
|
||||
</Select.ItemIndicator>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</MWSelect>
|
||||
);
|
||||
}
|
||||
|
||||
interface DefaultQualitySelectorProps extends SelectProps {
|
||||
qualityType: "wifi" | "data";
|
||||
}
|
||||
|
||||
export function DefaultQualitySelector(props: DefaultQualitySelectorProps) {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
wifiDefaultQuality,
|
||||
mobileDataDefaultQuality,
|
||||
setWifiDefaultQuality,
|
||||
setMobileDataDefaultQuality,
|
||||
} = useNetworkSettingsStore();
|
||||
|
||||
return (
|
||||
<MWSelect
|
||||
value={
|
||||
props.qualityType === "wifi"
|
||||
? wifiDefaultQuality
|
||||
: mobileDataDefaultQuality
|
||||
}
|
||||
onValueChange={
|
||||
props.qualityType === "wifi"
|
||||
? setWifiDefaultQuality
|
||||
: setMobileDataDefaultQuality
|
||||
}
|
||||
disablePreventBodyScroll
|
||||
{...props}
|
||||
>
|
||||
<MWSelect.Trigger
|
||||
maxWidth="$10"
|
||||
iconAfter={
|
||||
<FontAwesome name="chevron-down" color={theme.inputIconColor.val} />
|
||||
}
|
||||
>
|
||||
<Select.Value fontWeight="$semibold" textTransform="capitalize" />
|
||||
</MWSelect.Trigger>
|
||||
|
||||
<Adapt platform="native">
|
||||
<Sheet
|
||||
modal
|
||||
dismissOnSnapToBottom
|
||||
dismissOnOverlayPress
|
||||
animationConfig={{
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
mass: 1.2,
|
||||
stiffness: 250,
|
||||
}}
|
||||
snapPoints={[35]}
|
||||
>
|
||||
<Sheet.Handle backgroundColor="$sheetHandle" />
|
||||
<Sheet.Frame
|
||||
backgroundColor="$sheetBackground"
|
||||
padding="$4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Adapt.Contents />
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
</Sheet>
|
||||
</Adapt>
|
||||
|
||||
<Select.Content>
|
||||
<Select.Viewport
|
||||
animation="static"
|
||||
animateOnly={["transform", "opacity"]}
|
||||
enterStyle={{ o: 0, y: -10 }}
|
||||
exitStyle={{ o: 0, y: 10 }}
|
||||
>
|
||||
{defaultQualityOptions.map((item, i) => (
|
||||
<Select.Item
|
||||
index={i}
|
||||
key={item}
|
||||
value={item}
|
||||
backgroundColor="$sheetItemBackground"
|
||||
borderTopRightRadius={i === 0 ? "$8" : 0}
|
||||
borderTopLeftRadius={i === 0 ? "$8" : 0}
|
||||
borderBottomRightRadius={
|
||||
i === defaultQualityOptions.length - 1 ? "$8" : 0
|
||||
}
|
||||
borderBottomLeftRadius={
|
||||
i === defaultQualityOptions.length - 1 ? "$8" : 0
|
||||
}
|
||||
>
|
||||
<Select.ItemText
|
||||
textTransform="capitalize"
|
||||
fontWeight="$semibold"
|
||||
>
|
||||
{item}
|
||||
</Select.ItemText>
|
||||
<Select.ItemIndicator ml="auto">
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.sheetItemSelected.val}
|
||||
/>
|
||||
</Select.ItemIndicator>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</MWSelect>
|
||||
);
|
||||
}
|
||||
|
@@ -1,19 +1,23 @@
|
||||
import { View } from "react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import { Link, Stack } from "expo-router";
|
||||
|
||||
import { Text } from "~/components/ui/Text";
|
||||
import { Text, View } from "tamagui";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
if (Linking.useURL()) return null;
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<View className="flex-1 items-center justify-center p-5">
|
||||
<Text className="text-lg font-bold">
|
||||
This screen doesn't exist.
|
||||
</Text>
|
||||
<View flex={1} alignItems="center" justifyContent="center" padding={5}>
|
||||
<Text fontWeight="bold">This screen doesn't exist.</Text>
|
||||
|
||||
<Link href="/" className="mt-4 py-4">
|
||||
<Text className="text-sm text-sky-500">Go to home screen!</Text>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
paddingVertical: 16,
|
||||
}}
|
||||
>
|
||||
<Text color="skyblue">Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
|
@@ -1,18 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { useEffect } from "react";
|
||||
import { useColorScheme } from "react-native";
|
||||
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,
|
||||
DefaultTheme,
|
||||
ThemeProvider,
|
||||
} from "@react-navigation/native";
|
||||
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 Colors from "@movie-web/tailwind-config/colors";
|
||||
|
||||
import "./styles/global.css";
|
||||
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.
|
||||
@@ -29,6 +29,8 @@ 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"),
|
||||
@@ -57,25 +59,60 @@ export default function RootLayout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RootLayoutNav />;
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<RootLayoutNav />
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenStacks() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
autoHideHomeIndicator: true,
|
||||
gestureEnabled: true,
|
||||
animation: "default",
|
||||
animationTypeForReplace: "push",
|
||||
presentation: "card",
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.screenBackground.val,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
gestureEnabled: true,
|
||||
animation: "default",
|
||||
animationTypeForReplace: "push",
|
||||
presentation: "card",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
const themeStore = useThemeStore((s) => s.theme);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
gestureEnabled: true,
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: Colors.background,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TamaguiProvider config={tamaguiConfig} defaultTheme="main">
|
||||
<ToastProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Theme name={themeStore}>
|
||||
<ScreenStacks />
|
||||
</Theme>
|
||||
</ThemeProvider>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
</TamaguiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
@@ -1,36 +0,0 @@
|
||||
import { Image, View } from "react-native";
|
||||
|
||||
import { Text } from "~/components/ui/Text";
|
||||
|
||||
export interface ItemData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: "movie" | "tv";
|
||||
year: number;
|
||||
posterUrl: string;
|
||||
}
|
||||
|
||||
export default function Item({ data }: { data: ItemData }) {
|
||||
const { title, type, year, posterUrl } = data;
|
||||
|
||||
return (
|
||||
<View className="w-full">
|
||||
<View className="mb-2 aspect-[9/14] w-full overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
source={{
|
||||
uri: posterUrl,
|
||||
}}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</View>
|
||||
<Text className="font-bold">{title}</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Text className="text-xs text-gray-600">
|
||||
{type === "tv" ? "Show" : "Movie"}
|
||||
</Text>
|
||||
<View className="h-1 w-1 rounded-3xl bg-gray-600" />
|
||||
<Text className="text-sm text-gray-600">{year}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Text } from "~/components/ui/Text";
|
||||
|
||||
interface Props {
|
||||
title?: React.ReactNode | string;
|
||||
subtitle?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ScreenLayout({ title, subtitle, children }: Props) {
|
||||
return (
|
||||
<View className="bg-shade-900 flex-1 p-12">
|
||||
{typeof title === "string" && (
|
||||
<Text className="text-2xl font-bold">{title}</Text>
|
||||
)}
|
||||
{typeof title !== "string" && title}
|
||||
<Text className="mt-1 text-sm font-bold">{subtitle}</Text>
|
||||
<View className="py-3">{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
@@ -1,18 +0,0 @@
|
||||
import type { TextProps } from "react-native";
|
||||
import { Text as RNText } from "react-native";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "~/app/lib/utils";
|
||||
|
||||
const textVariants = cva("text-white");
|
||||
|
||||
export function Text({ className, ...props }: TextProps) {
|
||||
return (
|
||||
<RNText
|
||||
className={cn(className, textVariants(), {
|
||||
"font-sans": !className?.includes("font-"),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
import type { ClassValue } from "clsx";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
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>
|
||||
);
|
||||
}
|
138
apps/expo/src/components/DownloadItem.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { NativeSyntheticEvent } from "react-native";
|
||||
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
|
||||
import React from "react";
|
||||
import ContextMenu from "react-native-context-menu-view";
|
||||
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||
import { Image, Text, View, XStack, YStack } from "tamagui";
|
||||
|
||||
import type { Download } from "~/hooks/useDownloadManager";
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
import { MWProgress } from "./ui/Progress";
|
||||
import { FlashingText } from "./ui/Text";
|
||||
|
||||
export interface DownloadItemProps {
|
||||
item: Download;
|
||||
onPress: (localPath?: string) => void;
|
||||
}
|
||||
|
||||
enum ContextMenuActions {
|
||||
Cancel = "Cancel",
|
||||
Remove = "Remove",
|
||||
}
|
||||
|
||||
const statusToTextMap: Record<Download["status"], string> = {
|
||||
downloading: "Downloading",
|
||||
finished: "Finished",
|
||||
error: "Error",
|
||||
merging: "Merging",
|
||||
cancelled: "Cancelled",
|
||||
importing: "Importing",
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
export function DownloadItem(props: DownloadItemProps) {
|
||||
const percentage = props.item.progress * 100;
|
||||
const formattedFileSize = formatBytes(props.item.fileSize);
|
||||
const formattedDownloaded = formatBytes(props.item.downloaded);
|
||||
const { removeDownload, cancelDownload } = useDownloadManager();
|
||||
|
||||
const contextMenuActions = [
|
||||
{
|
||||
title: ContextMenuActions.Remove,
|
||||
},
|
||||
...(props.item.status !== "finished"
|
||||
? [{ title: ContextMenuActions.Cancel }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const onContextMenuPress = (
|
||||
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
|
||||
) => {
|
||||
if (e.nativeEvent.name === ContextMenuActions.Cancel) {
|
||||
void cancelDownload(props.item);
|
||||
} else if (e.nativeEvent.name === ContextMenuActions.Remove) {
|
||||
removeDownload(props.item);
|
||||
}
|
||||
};
|
||||
|
||||
const isInProgress = !(
|
||||
props.item.status === "finished" ||
|
||||
props.item.status === "error" ||
|
||||
props.item.status === "cancelled"
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
actions={contextMenuActions}
|
||||
onPress={onContextMenuPress}
|
||||
previewBackgroundColor="transparent"
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => props.onPress(props.item.localPath)}
|
||||
onLongPress={() => {
|
||||
return;
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<XStack gap="$4" alignItems="center">
|
||||
<View
|
||||
aspectRatio={9 / 14}
|
||||
width={70}
|
||||
maxHeight={180}
|
||||
overflow="hidden"
|
||||
borderRadius="$2"
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
|
||||
}}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</View>
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$6" maxWidth="65%">
|
||||
<Text fontWeight="$bold" ellipse flexGrow={1}>
|
||||
{props.item.media.title}
|
||||
</Text>
|
||||
{props.item.type !== "hls" && (
|
||||
<Text fontSize="$2" color="gray">
|
||||
{props.item.speed.toFixed(2)} MB/s
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
<MWProgress value={percentage} height={10} maxWidth="100%">
|
||||
<MWProgress.Indicator />
|
||||
</MWProgress>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$2" color="gray">
|
||||
{props.item.type === "hls"
|
||||
? `${percentage.toFixed()}% - ${props.item.downloaded} of ${props.item.fileSize} segments`
|
||||
: `${percentage.toFixed()}% - ${formattedDownloaded} of ${formattedFileSize}`}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<FlashingText
|
||||
isInProgress={isInProgress}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "gray",
|
||||
}}
|
||||
>
|
||||
{statusToTextMap[props.item.status]}
|
||||
</FlashingText>
|
||||
</View>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
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;
|
||||
}
|
@@ -1,17 +1,12 @@
|
||||
import { FontAwesome } from "@expo/vector-icons";
|
||||
|
||||
import Colors from "@movie-web/tailwind-config/colors";
|
||||
import { useTheme } from "tamagui";
|
||||
|
||||
type Props = {
|
||||
focused?: boolean;
|
||||
} & React.ComponentProps<typeof FontAwesome>;
|
||||
|
||||
export default function TabBarIcon({ focused, ...rest }: Props) {
|
||||
return (
|
||||
<FontAwesome
|
||||
color={focused ? Colors.primary[300] : Colors.secondary[300]}
|
||||
size={24}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
const theme = useTheme();
|
||||
const color = focused ? theme.tabBarIconFocused.val : theme.tabBarIcon.val;
|
||||
return <FontAwesome color={color} size={24} {...rest} />;
|
||||
}
|
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>
|
||||
);
|
||||
};
|
149
apps/expo/src/components/item/item.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { NativeSyntheticEvent } from "react-native";
|
||||
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
|
||||
import { useCallback } from "react";
|
||||
import { Keyboard, TouchableOpacity } from "react-native";
|
||||
import ContextMenu from "react-native-context-menu-view";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Image, Text, View } from "tamagui";
|
||||
|
||||
import { useToast } from "~/hooks/useToast";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
|
||||
|
||||
export interface ItemData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: "movie" | "tv";
|
||||
year: number;
|
||||
release_date?: Date;
|
||||
posterUrl: string;
|
||||
}
|
||||
|
||||
enum ContextMenuActions {
|
||||
Bookmark = "Bookmark",
|
||||
RemoveBookmark = "Remove Bookmark",
|
||||
Download = "Download",
|
||||
RemoveWatchHistoryItem = "Remove from Continue Watching",
|
||||
}
|
||||
|
||||
function checkReleased(media: ItemData): boolean {
|
||||
const isReleasedYear = Boolean(
|
||||
media.year && media.year <= new Date().getFullYear(),
|
||||
);
|
||||
const isReleasedDate = Boolean(
|
||||
media.release_date && media.release_date <= new Date(),
|
||||
);
|
||||
|
||||
// If the media has a release date, use that, otherwise use the year
|
||||
const isReleased = media.release_date ? isReleasedDate : isReleasedYear;
|
||||
|
||||
return isReleased;
|
||||
}
|
||||
|
||||
export default function Item({ data }: { data: ItemData }) {
|
||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||
const router = useRouter();
|
||||
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
|
||||
const { hasWatchHistoryItem, removeFromWatchHistory } =
|
||||
useWatchHistoryStore();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const { title, type, year, posterUrl } = data;
|
||||
|
||||
const isReleased = useCallback(() => checkReleased(data), [data]);
|
||||
|
||||
const handlePress = () => {
|
||||
if (!isReleased()) {
|
||||
showToast("This media is not released yet", {
|
||||
burntOptions: { preset: "error" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
resetVideo();
|
||||
Keyboard.dismiss();
|
||||
router.push({
|
||||
pathname: "/videoPlayer",
|
||||
params: { data: JSON.stringify(data) },
|
||||
});
|
||||
};
|
||||
|
||||
const contextMenuActions = [
|
||||
{
|
||||
title: isBookmarked(data)
|
||||
? ContextMenuActions.RemoveBookmark
|
||||
: ContextMenuActions.Bookmark,
|
||||
},
|
||||
...(type === "movie" ? [{ title: ContextMenuActions.Download }] : []),
|
||||
...(hasWatchHistoryItem(data)
|
||||
? [{ title: ContextMenuActions.RemoveWatchHistoryItem }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const onContextMenuPress = (
|
||||
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
|
||||
) => {
|
||||
if (e.nativeEvent.name === ContextMenuActions.Bookmark) {
|
||||
addBookmark(data);
|
||||
showToast("Added to bookmarks", {
|
||||
burntOptions: { preset: "done" },
|
||||
});
|
||||
} else if (e.nativeEvent.name === ContextMenuActions.RemoveBookmark) {
|
||||
removeBookmark(data);
|
||||
showToast("Removed from bookmarks", {
|
||||
burntOptions: { preset: "done" },
|
||||
});
|
||||
} else if (e.nativeEvent.name === ContextMenuActions.Download) {
|
||||
router.push({
|
||||
pathname: "/videoPlayer",
|
||||
params: { data: JSON.stringify(data), download: "true" },
|
||||
});
|
||||
} else if (
|
||||
e.nativeEvent.name === ContextMenuActions.RemoveWatchHistoryItem
|
||||
) {
|
||||
removeFromWatchHistory(data);
|
||||
showToast("Removed from Continue Watching", {
|
||||
burntOptions: { preset: "done" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onLongPress={() => {}}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<View width="100%">
|
||||
<ContextMenu actions={contextMenuActions} onPress={onContextMenuPress}>
|
||||
<View
|
||||
marginBottom={4}
|
||||
aspectRatio={9 / 14}
|
||||
width="100%"
|
||||
overflow="hidden"
|
||||
borderRadius={24}
|
||||
>
|
||||
<Image source={{ uri: posterUrl }} width="100%" height="100%" />
|
||||
</View>
|
||||
</ContextMenu>
|
||||
<Text fontWeight="bold" fontSize={14}>
|
||||
{title}
|
||||
</Text>
|
||||
<View flexDirection="row" alignItems="center" gap={3}>
|
||||
<Text fontSize={12} color="$ash100">
|
||||
{type === "tv" ? "Show" : "Movie"}
|
||||
</Text>
|
||||
<View
|
||||
height={6}
|
||||
width={6}
|
||||
borderRadius={24}
|
||||
backgroundColor="$ash100"
|
||||
/>
|
||||
<Text fontSize={12} color="$ash100">
|
||||
{isReleased() ? year : "Unreleased"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
56
apps/expo/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Linking } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { FontAwesome6, MaterialIcons } from "@expo/vector-icons";
|
||||
import { Circle, View } from "tamagui";
|
||||
|
||||
import { DISCORD_LINK, GITHUB_LINK } from "~/constants/core";
|
||||
import { BrandPill } from "../BrandPill";
|
||||
|
||||
export function Header() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
paddingTop={insets.top}
|
||||
alignItems="center"
|
||||
gap="$3"
|
||||
flexDirection="row"
|
||||
>
|
||||
<BrandPill />
|
||||
|
||||
<Circle
|
||||
backgroundColor="$pillBackground"
|
||||
size="$4.5"
|
||||
pressStyle={{
|
||||
opacity: 1,
|
||||
scale: 1.05,
|
||||
}}
|
||||
onPress={async () => {
|
||||
await Linking.openURL(DISCORD_LINK);
|
||||
}}
|
||||
onLongPress={() =>
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||
}
|
||||
>
|
||||
<MaterialIcons name="discord" size={32} color="white" />
|
||||
</Circle>
|
||||
<Circle
|
||||
backgroundColor="$pillBackground"
|
||||
size="$4.5"
|
||||
pressStyle={{
|
||||
opacity: 1,
|
||||
scale: 1.05,
|
||||
}}
|
||||
onPress={async () => {
|
||||
await Linking.openURL(GITHUB_LINK);
|
||||
}}
|
||||
onLongPress={() =>
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||
}
|
||||
>
|
||||
<FontAwesome6 name="github" size={32} color="white" />
|
||||
</Circle>
|
||||
</View>
|
||||
);
|
||||
}
|
58
apps/expo/src/components/layout/ScreenLayout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ScrollView } from "tamagui";
|
||||
import { LinearGradient } from "tamagui/linear-gradient";
|
||||
|
||||
import { Header } from "./Header";
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
onScrollBeginDrag?: () => void;
|
||||
onMomentumScrollEnd?: () => void;
|
||||
scrollEnabled?: boolean;
|
||||
keyboardDismissMode?: "none" | "on-drag" | "interactive";
|
||||
keyboardShouldPersistTaps?: "always" | "never" | "handled";
|
||||
contentContainerStyle?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function ScreenLayout({
|
||||
children,
|
||||
onScrollBeginDrag,
|
||||
onMomentumScrollEnd,
|
||||
scrollEnabled,
|
||||
keyboardDismissMode,
|
||||
keyboardShouldPersistTaps,
|
||||
contentContainerStyle,
|
||||
}: Props) {
|
||||
return (
|
||||
<LinearGradient
|
||||
flex={1}
|
||||
paddingVertical="$4"
|
||||
paddingHorizontal="$4"
|
||||
colors={[
|
||||
"$shade900",
|
||||
"$purple900",
|
||||
"$purple800",
|
||||
"$shade700",
|
||||
"$shade900",
|
||||
]}
|
||||
locations={[0.02, 0.15, 0.2, 0.4, 0.8]}
|
||||
start={[0, 0]}
|
||||
end={[1, 1]}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Header />
|
||||
<ScrollView
|
||||
onScrollBeginDrag={onScrollBeginDrag}
|
||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||
scrollEnabled={scrollEnabled}
|
||||
keyboardDismissMode={keyboardDismissMode}
|
||||
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
marginTop="$4"
|
||||
flexGrow={1}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{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.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
Audio
|
||||
</MWButton>
|
||||
</Controls>
|
||||
|
||||
<Settings.Sheet
|
||||
forceRemoveScrollEnabled={open}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
onPress={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
title="Audio"
|
||||
/>
|
||||
<Settings.Content>
|
||||
{tracks?.map((track) => (
|
||||
<Settings.Item
|
||||
key={track.language}
|
||||
title={track.name}
|
||||
iconRight={
|
||||
track.active && (
|
||||
<MaterialCommunityIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={() => {
|
||||
setSelectedAudioTrack(track);
|
||||
if (stream) {
|
||||
void synchronizePlayback(track, stream);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Settings.Content>
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
97
apps/expo/src/components/player/BottomControls.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { isDevelopmentProvisioningProfile } from "modules/check-ios-certificate";
|
||||
import { Text, View } from "tamagui";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { CaptionsSelector } from "./CaptionsSelector";
|
||||
import { Controls } from "./Controls";
|
||||
import { DownloadButton } from "./DownloadButton";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { SeasonSelector } from "./SeasonEpisodeSelector";
|
||||
import { SettingsSelector } from "./SettingsSelector";
|
||||
import { SourceSelector } from "./SourceSelector";
|
||||
import { mapMillisecondsToTime } from "./utils";
|
||||
|
||||
export const BottomControls = () => {
|
||||
const status = usePlayerStore((state) => state.status);
|
||||
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
|
||||
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
|
||||
const [showRemaining, setShowRemaining] = useState(false);
|
||||
|
||||
const toggleTimeDisplay = useCallback(() => {
|
||||
setIsIdle(false);
|
||||
setShowRemaining(!showRemaining);
|
||||
}, [showRemaining, setIsIdle]);
|
||||
|
||||
const { currentTime, remainingTime } = useMemo(() => {
|
||||
if (status?.isLoaded) {
|
||||
const current = mapMillisecondsToTime(status.positionMillis ?? 0);
|
||||
const remaining =
|
||||
"-" +
|
||||
mapMillisecondsToTime(
|
||||
(status.durationMillis ?? 0) - (status.positionMillis ?? 0),
|
||||
);
|
||||
return { currentTime: current, remainingTime: remaining };
|
||||
} else {
|
||||
return { currentTime: "", remainingTime: "" };
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const durationTime = useMemo(() => {
|
||||
if (status?.isLoaded) {
|
||||
return mapMillisecondsToTime(status.durationMillis ?? 0);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
if (status?.isLoaded) {
|
||||
return (
|
||||
<View
|
||||
height={128}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={24}
|
||||
>
|
||||
<Controls>
|
||||
<View flexDirection="row" justifyContent="space-between" width="$11">
|
||||
<Text fontWeight="bold">{currentTime}</Text>
|
||||
<Text marginHorizontal={1} fontWeight="bold">
|
||||
/
|
||||
</Text>
|
||||
<TouchableOpacity onPress={toggleTimeDisplay}>
|
||||
<Text fontWeight="bold">
|
||||
{showRemaining ? remainingTime : durationTime}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ProgressBar />
|
||||
</Controls>
|
||||
<View
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
paddingBottom={40}
|
||||
>
|
||||
{!isLocalFile && (
|
||||
<>
|
||||
<SeasonSelector />
|
||||
<CaptionsSelector />
|
||||
<SourceSelector />
|
||||
<AudioTrackSelector />
|
||||
<SettingsSelector />
|
||||
{Platform.OS === "android" ||
|
||||
(Platform.OS === "ios" && isDevelopmentProvisioningProfile()) ? (
|
||||
<DownloadButton />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
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.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
Subtitles
|
||||
</MWButton>
|
||||
</Controls>
|
||||
<Settings.Sheet
|
||||
forceRemoveScrollEnabled={open}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<MaterialIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
onPress={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
title="Subtitles"
|
||||
rightButton={
|
||||
<MWButton
|
||||
color="$playerSettingsUnactiveText"
|
||||
fontWeight="bold"
|
||||
chromeless
|
||||
onPress={() => {
|
||||
showToast("Work in progress");
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</MWButton>
|
||||
}
|
||||
/>
|
||||
<Settings.Content>
|
||||
<Settings.Item
|
||||
iconLeft={
|
||||
<View
|
||||
width="$5"
|
||||
height="$3"
|
||||
backgroundColor="$subtitleSelectorBackground"
|
||||
borderRadius="$5"
|
||||
/>
|
||||
}
|
||||
title={"Off"}
|
||||
iconRight={
|
||||
<>
|
||||
{!selectedCaption?.id && (
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.sheetItemSelected.val}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onPress={() => setSelectedCaption(null)}
|
||||
/>
|
||||
|
||||
{captions?.map((caption) => (
|
||||
<Settings.Item
|
||||
iconLeft={
|
||||
<View
|
||||
width="$5"
|
||||
height="$3"
|
||||
backgroundColor="$subtitleSelectorBackground"
|
||||
borderRadius="$5"
|
||||
overflow="hidden"
|
||||
>
|
||||
<FlagIcon
|
||||
languageCode={getCountryCodeFromLanguage(
|
||||
caption.language as LanguageCode,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={getPrettyLanguageNameFromLocale(caption.language) ?? ""}
|
||||
iconRight={
|
||||
<>
|
||||
{selectedCaption?.id === caption.id && (
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.sheetItemSelected.val}
|
||||
/>
|
||||
)}
|
||||
{downloadCaption.isPending &&
|
||||
downloadCaption.variables.id === caption.id && (
|
||||
<Spinner size="small" color="$loadingIndicator" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onPress={() => downloadCaption.mutate(caption)}
|
||||
key={caption.id}
|
||||
/>
|
||||
))}
|
||||
</Settings.Content>
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
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 { findHighestQuality } from "@movie-web/provider-utils";
|
||||
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Controls } from "./Controls";
|
||||
|
||||
export const DownloadButton = () => {
|
||||
const theme = useTheme();
|
||||
const { startDownload } = useDownloadManager();
|
||||
const stream = usePlayerStore((state) => state.interface.currentStream);
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
|
||||
if (!meta) return null;
|
||||
|
||||
const scrapeMedia = convertMetaToScrapeMedia(meta);
|
||||
let url: string | undefined | null = null;
|
||||
|
||||
if (stream?.type === "file") {
|
||||
const highestQuality = findHighestQuality(stream);
|
||||
url = highestQuality ? stream.qualities[highestQuality]?.url : null;
|
||||
} else if (stream?.type === "hls") {
|
||||
url = stream.playlist;
|
||||
}
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={() =>
|
||||
url &&
|
||||
startDownload(
|
||||
url,
|
||||
stream?.type === "hls" ? "hls" : "mp4",
|
||||
scrapeMedia,
|
||||
).catch(console.error)
|
||||
}
|
||||
>
|
||||
Download
|
||||
</MWButton>
|
||||
</Controls>
|
||||
</>
|
||||
);
|
||||
};
|
48
apps/expo/src/components/player/Header.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Text, View } from "tamagui";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { BrandPill } from "../BrandPill";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { Controls } from "./Controls";
|
||||
|
||||
const mapSeasonAndEpisodeNumberToText = (season: number, episode: number) => {
|
||||
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
export const Header = () => {
|
||||
const isIdle = usePlayerStore((state) => state.interface.isIdle);
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
|
||||
if (!isIdle) {
|
||||
return (
|
||||
<View
|
||||
zIndex={50}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
height={64}
|
||||
paddingHorizontal="$8"
|
||||
>
|
||||
<View width={150}>
|
||||
<Controls>
|
||||
<BackButton />
|
||||
</Controls>
|
||||
</View>
|
||||
{meta && (
|
||||
<Text fontWeight="bold">
|
||||
{meta.title} ({meta.releaseYear}){" "}
|
||||
{meta.season !== undefined && meta.episode !== undefined
|
||||
? mapSeasonAndEpisodeNumberToText(
|
||||
meta.season.number,
|
||||
meta.episode.number,
|
||||
)
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
<View alignItems="center" justifyContent="center" width={150}>
|
||||
<BrandPill />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
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>
|
||||
);
|
||||
}
|
232
apps/expo/src/components/player/ScraperProcess.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { SafeAreaView } from "react-native";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { useRouter } from "expo-router";
|
||||
import { View } from "tamagui";
|
||||
|
||||
import type {
|
||||
HlsBasedStream,
|
||||
RunOutput,
|
||||
ScrapeMedia,
|
||||
} from "@movie-web/provider-utils";
|
||||
import {
|
||||
constructFullUrl,
|
||||
extractTracksFromHLS,
|
||||
findHighestQuality,
|
||||
} from "@movie-web/provider-utils";
|
||||
|
||||
import type { ItemData } from "../item/item";
|
||||
import type { AudioTrack } from "./AudioTrackSelector";
|
||||
import type { PlayerMeta } from "~/stores/player/slices/video";
|
||||
import { useMeta } from "~/hooks/player/useMeta";
|
||||
import { useScrape } from "~/hooks/player/useSourceScrape";
|
||||
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { ScrapeCard, ScrapeItem } from "./ScrapeCard";
|
||||
|
||||
interface ScraperProcessProps {
|
||||
data?: Partial<ItemData>;
|
||||
media?: ScrapeMedia;
|
||||
download?: boolean;
|
||||
}
|
||||
|
||||
export const ScraperProcess = ({
|
||||
data,
|
||||
media,
|
||||
download,
|
||||
}: ScraperProcessProps) => {
|
||||
const router = useRouter();
|
||||
const { startDownload } = useDownloadManager();
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const { convertIdToMeta } = useMeta();
|
||||
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
|
||||
|
||||
const setStream = usePlayerStore((state) => state.setCurrentStream);
|
||||
const setHlsTracks = usePlayerStore((state) => state.setHlsTracks);
|
||||
const setAudioTracks = usePlayerStore((state) => state.setAudioTracks);
|
||||
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
|
||||
const setSourceId = usePlayerStore((state) => state.setSourceId);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!data?.id && !media) return router.back();
|
||||
|
||||
let streamResult: RunOutput | null = null;
|
||||
let meta: PlayerMeta | undefined = undefined;
|
||||
|
||||
if (!media && data?.id && data.type) {
|
||||
meta = await convertIdToMeta(data.id, data.type);
|
||||
if (!meta) return router.back();
|
||||
}
|
||||
|
||||
const scrapeMedia = media ?? (meta && convertMetaToScrapeMedia(meta));
|
||||
if (!scrapeMedia) return router.back();
|
||||
streamResult = await startScraping(scrapeMedia);
|
||||
|
||||
if (!streamResult) return router.back();
|
||||
if (download) {
|
||||
if (streamResult.stream.type === "file") {
|
||||
const highestQuality = findHighestQuality(streamResult.stream);
|
||||
const url = highestQuality
|
||||
? streamResult.stream.qualities[highestQuality]?.url
|
||||
: null;
|
||||
if (!url) return;
|
||||
startDownload(url, "mp4", scrapeMedia).catch(console.error);
|
||||
} else if (streamResult.stream.type === "hls") {
|
||||
startDownload(streamResult.stream.playlist, "hls", scrapeMedia).catch(
|
||||
console.error,
|
||||
);
|
||||
}
|
||||
return router.back();
|
||||
}
|
||||
setStream(streamResult.stream);
|
||||
|
||||
if (streamResult.stream.type === "hls") {
|
||||
const tracks = await extractTracksFromHLS(
|
||||
streamResult.stream.playlist,
|
||||
{
|
||||
...streamResult.stream.preferredHeaders,
|
||||
...streamResult.stream.headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (tracks) setHlsTracks(tracks);
|
||||
|
||||
if (tracks?.audio.length) {
|
||||
const audioTracks: AudioTrack[] = tracks.audio.map((track) => ({
|
||||
uri: constructFullUrl(
|
||||
(streamResult?.stream as HlsBasedStream).playlist,
|
||||
track.uri,
|
||||
),
|
||||
name: track.properties[0]?.attributes.name?.toString() ?? "Unknown",
|
||||
language:
|
||||
track.properties[0]?.attributes.language?.toString() ?? "Unknown",
|
||||
active: Boolean(track.properties[0]?.attributes.default) ?? false,
|
||||
}));
|
||||
|
||||
const uniqueTracks = new Set(audioTracks.map((t) => t.language));
|
||||
|
||||
const filteredAudioTracks = audioTracks.filter((track) => {
|
||||
if (uniqueTracks.has(track.language)) {
|
||||
uniqueTracks.delete(track.language);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
setAudioTracks(filteredAudioTracks);
|
||||
}
|
||||
}
|
||||
setPlayerStatus(PlayerStatus.READY);
|
||||
setSourceId(streamResult.sourceId);
|
||||
};
|
||||
void fetchData();
|
||||
}, [
|
||||
convertIdToMeta,
|
||||
data,
|
||||
download,
|
||||
media,
|
||||
router,
|
||||
setAudioTracks,
|
||||
setHlsTracks,
|
||||
setPlayerStatus,
|
||||
setSourceId,
|
||||
setStream,
|
||||
startDownload,
|
||||
startScraping,
|
||||
]);
|
||||
|
||||
let currentProviderIndex = sourceOrder.findIndex(
|
||||
(s) => s.id === currentSource || s.children.includes(currentSource ?? ""),
|
||||
);
|
||||
if (currentProviderIndex === -1) {
|
||||
currentProviderIndex = sourceOrder.length - 1;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollTo({
|
||||
y: currentProviderIndex * 110,
|
||||
animated: true,
|
||||
});
|
||||
}, [currentProviderIndex]);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
flex={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="$screenBackground"
|
||||
>
|
||||
<View position="absolute" top={40} left={40}>
|
||||
<BackButton />
|
||||
</View>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 64,
|
||||
}}
|
||||
>
|
||||
{sourceOrder.map((order) => {
|
||||
const source = sources[order.id];
|
||||
if (!source) return null;
|
||||
const distance = Math.abs(
|
||||
sourceOrder.findIndex((o) => o.id === order.id) -
|
||||
currentProviderIndex,
|
||||
);
|
||||
return (
|
||||
<View
|
||||
key={order.id}
|
||||
style={{ opacity: Math.max(0, 1 - distance * 0.3) }}
|
||||
>
|
||||
<ScrapeCard
|
||||
id={order.id}
|
||||
name={source.name}
|
||||
status={source.status}
|
||||
hasChildren={order.children.length > 0}
|
||||
percentage={source.percentage}
|
||||
>
|
||||
<View
|
||||
marginTop={order.children.length > 0 ? 8 : 0}
|
||||
flexDirection="column"
|
||||
gap={16}
|
||||
>
|
||||
{order.children.map((embedId) => {
|
||||
const embed = sources[embedId];
|
||||
if (!embed) return null;
|
||||
return (
|
||||
<ScrapeItem
|
||||
id={embedId}
|
||||
name={embed.name}
|
||||
status={embed.status}
|
||||
percentage={embed.percentage}
|
||||
key={embedId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrapeCard>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
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.buttonSecondaryText.val}
|
||||
onPress={() => {
|
||||
setSelectedSeason(null);
|
||||
props.onOpenChange?.(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={`Season ${data?.season_number}`}
|
||||
/>
|
||||
<Settings.Content>
|
||||
{data?.episodes.map((episode) => (
|
||||
<Settings.Item
|
||||
key={episode.id}
|
||||
iconLeft={
|
||||
<View
|
||||
width={32}
|
||||
height={32}
|
||||
backgroundColor="#121c24"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
borderRadius={6}
|
||||
>
|
||||
<Settings.Text fontSize={14}>
|
||||
E{episode.episode_number}
|
||||
</Settings.Text>
|
||||
</View>
|
||||
}
|
||||
title={episode.name}
|
||||
onPress={() => {
|
||||
setMeta({
|
||||
...meta,
|
||||
episode: {
|
||||
number: episode.episode_number,
|
||||
tmdbId: episode.id.toString(),
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Settings.Content>
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export const SeasonSelector = () => {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [episodeOpen, setEpisodeOpen] = useState(false);
|
||||
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["seasons", meta!.tmdbId],
|
||||
queryFn: async () => {
|
||||
return fetchMediaDetails(meta!.tmdbId, "tv");
|
||||
},
|
||||
enabled: meta !== null,
|
||||
});
|
||||
|
||||
if (meta?.type !== "show") return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="audio-video"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
Episodes
|
||||
</MWButton>
|
||||
</Controls>
|
||||
|
||||
<Settings.Sheet
|
||||
forceRemoveScrollEnabled={open}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame isLoading={isLoading}>
|
||||
{episodeOpen && selectedSeason ? (
|
||||
<EpisodeSelector
|
||||
seasonNumber={selectedSeason}
|
||||
setSelectedSeason={setSelectedSeason}
|
||||
open={episodeOpen}
|
||||
onOpenChange={setEpisodeOpen}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
onPress={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
title={data?.result.name ?? ""}
|
||||
/>
|
||||
<Settings.Content>
|
||||
{data?.result.seasons.map((season) => (
|
||||
<Settings.Item
|
||||
key={season.season_number}
|
||||
title={`Season ${season.season_number}`}
|
||||
iconRight={
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
}
|
||||
onPress={() => {
|
||||
setSelectedSeason(season.season_number);
|
||||
setEpisodeOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Settings.Content>
|
||||
</>
|
||||
)}
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
36
apps/expo/src/components/player/SeekButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
|
||||
interface SeekProps {
|
||||
type: "forward" | "backward";
|
||||
}
|
||||
|
||||
export const SeekButton = ({ type }: SeekProps) => {
|
||||
const videoRef = usePlayerStore((state) => state.videoRef);
|
||||
const status = usePlayerStore((state) => state.status);
|
||||
const setAudioPositionAsync = usePlayerStore(
|
||||
(state) => state.setAudioPositionAsync,
|
||||
);
|
||||
|
||||
return (
|
||||
<MaterialIcons
|
||||
name={type === "forward" ? "forward-10" : "replay-10"}
|
||||
size={36}
|
||||
color="white"
|
||||
onPress={() => {
|
||||
if (status?.isLoaded) {
|
||||
const position =
|
||||
type === "forward"
|
||||
? status.positionMillis + 10000
|
||||
: status.positionMillis - 10000;
|
||||
|
||||
videoRef?.setPositionAsync(position).catch(() => {
|
||||
console.log("Error seeking backwards");
|
||||
});
|
||||
void setAudioPositionAsync(position);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
101
apps/expo/src/components/player/SettingsSelector.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
|
||||
import { useTheme } from "tamagui";
|
||||
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Controls } from "./Controls";
|
||||
import { PlaybackSpeedSelector } from "./PlaybackSpeedSelector";
|
||||
import { QualitySelector } from "./QualitySelector";
|
||||
import { Settings } from "./settings/Sheet";
|
||||
|
||||
export const SettingsSelector = () => {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [qualityOpen, setQualityOpen] = useState(false);
|
||||
const [playbackOpen, setPlaybackOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
icon={
|
||||
<MaterialIcons
|
||||
name="display-settings"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
Settings
|
||||
</MWButton>
|
||||
</Controls>
|
||||
|
||||
<Settings.Sheet
|
||||
forceRemoveScrollEnabled={open}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame>
|
||||
<QualitySelector open={qualityOpen} onOpenChange={setQualityOpen} />
|
||||
<PlaybackSpeedSelector
|
||||
open={playbackOpen}
|
||||
onOpenChange={setPlaybackOpen}
|
||||
/>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<MaterialIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
onPress={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
title="Settings"
|
||||
/>
|
||||
<Settings.Content>
|
||||
<Settings.Item
|
||||
title="Quality"
|
||||
iconLeft={
|
||||
<MaterialIcons
|
||||
name="hd"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
/>
|
||||
}
|
||||
iconRight={
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
}
|
||||
onPress={() => setQualityOpen(true)}
|
||||
/>
|
||||
<Settings.Item
|
||||
title="Playback speed"
|
||||
iconLeft={
|
||||
<MaterialIcons
|
||||
name="speed"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
/>
|
||||
}
|
||||
iconRight={
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
}
|
||||
onPress={() => setPlaybackOpen(true)}
|
||||
/>
|
||||
</Settings.Content>
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
222
apps/expo/src/components/player/SourceSelector.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import type { SheetProps } from "tamagui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Spinner, Text, useTheme, View } from "tamagui";
|
||||
|
||||
import { getBuiltinSources, providers } from "@movie-web/provider-utils";
|
||||
|
||||
import {
|
||||
useEmbedScrape,
|
||||
useSourceScrape,
|
||||
} from "~/hooks/player/useSourceScrape";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Controls } from "./Controls";
|
||||
import { Settings } from "./settings/Sheet";
|
||||
|
||||
const SourceItem = ({
|
||||
name,
|
||||
id,
|
||||
active,
|
||||
embed,
|
||||
onPress,
|
||||
}: {
|
||||
name: string;
|
||||
id: string;
|
||||
active?: boolean;
|
||||
embed?: { url: string; embedId: string };
|
||||
onPress?: (id: string) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { mutate, isPending, isError } = useEmbedScrape();
|
||||
|
||||
return (
|
||||
<Settings.Item
|
||||
title={name}
|
||||
iconRight={
|
||||
<>
|
||||
{active && (
|
||||
<MaterialCommunityIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={theme.sheetItemSelected.val}
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<MaterialCommunityIcons
|
||||
name="alert-circle"
|
||||
size={24}
|
||||
color={theme.scrapingError.val}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPending && <Spinner size="small" color="$scrapingLoading" />}
|
||||
</>
|
||||
}
|
||||
onPress={() => {
|
||||
if (onPress) {
|
||||
onPress(id);
|
||||
return;
|
||||
}
|
||||
if (embed) {
|
||||
mutate({
|
||||
url: embed.url,
|
||||
embedId: embed.embedId,
|
||||
sourceId: id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EmbedsPart = ({
|
||||
sourceId,
|
||||
closeParent,
|
||||
...props
|
||||
}: SheetProps & {
|
||||
sourceId: string;
|
||||
closeParent?: (open: boolean) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { data, isPending, isError, error, status } = useSourceScrape(sourceId);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "success" && !isError && data && data?.length <= 1) {
|
||||
props.onOpenChange?.(false);
|
||||
closeParent?.(false);
|
||||
}
|
||||
}, [status, data, isError, props, closeParent]);
|
||||
|
||||
return (
|
||||
<Settings.Sheet
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
{...props}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<Ionicons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
onPress={() => {
|
||||
props.onOpenChange?.(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={providers.getMetadata(sourceId)?.name ?? "Embeds"}
|
||||
/>
|
||||
<Settings.Content>
|
||||
<View alignItems="center" justifyContent="center">
|
||||
{isPending && <Spinner size="small" color="$loadingIndicator" />}
|
||||
{error && <Text>Something went wrong!</Text>}
|
||||
</View>
|
||||
{data && data?.length > 1 && (
|
||||
<Settings.Content>
|
||||
{data.map((embed) => {
|
||||
const metaData = providers.getMetadata(embed.embedId)!;
|
||||
return (
|
||||
<SourceItem
|
||||
key={embed.embedId}
|
||||
name={metaData.name}
|
||||
id={embed.embedId}
|
||||
embed={embed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Settings.Content>
|
||||
)}
|
||||
</Settings.Content>
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export const SourceSelector = () => {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [embedOpen, setEmbedOpen] = useState(false);
|
||||
|
||||
const sourceId = usePlayerStore((state) => state.interface.sourceId);
|
||||
const setSourceId = usePlayerStore((state) => state.setSourceId);
|
||||
|
||||
const isActive = useCallback(
|
||||
(id: string) => {
|
||||
return sourceId === id;
|
||||
},
|
||||
[sourceId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="video"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
Source
|
||||
</MWButton>
|
||||
</Controls>
|
||||
|
||||
<Settings.Sheet
|
||||
forceRemoveScrollEnabled={open}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Settings.SheetOverlay />
|
||||
<Settings.SheetHandle />
|
||||
<Settings.SheetFrame>
|
||||
{embedOpen && sourceId ? (
|
||||
<EmbedsPart
|
||||
sourceId={sourceId}
|
||||
open={embedOpen}
|
||||
onOpenChange={setEmbedOpen}
|
||||
closeParent={setOpen}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Settings.Header
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={theme.playerSettingsUnactiveText.val}
|
||||
onPress={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
title="Sources"
|
||||
/>
|
||||
<Settings.Content>
|
||||
{getBuiltinSources()
|
||||
.sort((a, b) => b.rank - a.rank)
|
||||
.map((source) => (
|
||||
<SourceItem
|
||||
key={source.id}
|
||||
name={source.name}
|
||||
id={source.id}
|
||||
active={isActive(source.id)}
|
||||
onPress={(id) => {
|
||||
setSourceId(id);
|
||||
setEmbedOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Settings.Content>
|
||||
</>
|
||||
)}
|
||||
</Settings.SheetFrame>
|
||||
</Settings.Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
72
apps/expo/src/components/player/StatusCircle.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedProps,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Circle, Svg } from "react-native-svg";
|
||||
import { AntDesign } from "@expo/vector-icons";
|
||||
import { View } from "tamagui";
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
export const StatusCircle = ({
|
||||
type,
|
||||
percentage = 0,
|
||||
}: {
|
||||
type: string;
|
||||
percentage: number;
|
||||
}) => {
|
||||
const radius = 25;
|
||||
const strokeWidth = 5;
|
||||
const circleCircumference = 2 * Math.PI * radius;
|
||||
|
||||
const strokeDashoffset = useSharedValue(circleCircumference);
|
||||
|
||||
React.useEffect(() => {
|
||||
strokeDashoffset.value = withTiming(
|
||||
circleCircumference - (circleCircumference * percentage) / 100,
|
||||
{
|
||||
duration: 500,
|
||||
easing: Easing.linear,
|
||||
},
|
||||
);
|
||||
}, [circleCircumference, percentage, strokeDashoffset]);
|
||||
|
||||
const animatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: strokeDashoffset.value,
|
||||
}));
|
||||
|
||||
const renderIcon = () => {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <AntDesign name="checkcircle" size={50} color="green" />;
|
||||
case "error":
|
||||
return <AntDesign name="closecircle" size={50} color="red" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View justifyContent="center" alignItems="center" position="relative">
|
||||
<Svg height="60" width="60" viewBox="0 0 60 60">
|
||||
{type === "loading" && (
|
||||
<AnimatedCircle
|
||||
cx="30"
|
||||
cy="30"
|
||||
r={radius}
|
||||
stroke="blue"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circleCircumference}
|
||||
animatedProps={animatedProps}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
{renderIcon()}
|
||||
</View>
|
||||
);
|
||||
};
|
383
apps/expo/src/components/player/VideoPlayer.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import type { AVPlaybackStatus } from "expo-av";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dimensions, Platform } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ResizeMode, Video } from "expo-av";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as StatusBar from "expo-status-bar";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Spinner, useTheme, View } from "tamagui";
|
||||
|
||||
import { findHighestQuality } from "@movie-web/provider-utils";
|
||||
|
||||
import { useAudioTrack } from "~/hooks/player/useAudioTrack";
|
||||
import { useBrightness } from "~/hooks/player/useBrightness";
|
||||
import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed";
|
||||
import { usePlayer } from "~/hooks/player/usePlayer";
|
||||
import { useVolume } from "~/hooks/player/useVolume";
|
||||
import {
|
||||
convertMetaToItemData,
|
||||
convertMetaToScrapeMedia,
|
||||
getNextEpisode,
|
||||
} from "~/lib/meta";
|
||||
import { useAudioTrackStore } from "~/stores/audio";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import {
|
||||
usePlayerSettingsStore,
|
||||
useWatchHistoryStore,
|
||||
} from "~/stores/settings";
|
||||
import { CaptionRenderer } from "./CaptionRenderer";
|
||||
import { ControlsOverlay } from "./ControlsOverlay";
|
||||
|
||||
export const VideoPlayer = () => {
|
||||
const {
|
||||
brightness,
|
||||
showBrightnessOverlay,
|
||||
setShowBrightnessOverlay,
|
||||
handleBrightnessChange,
|
||||
} = useBrightness();
|
||||
const { volume, showVolumeOverlay, setShowVolumeOverlay } = useVolume();
|
||||
|
||||
const { currentSpeed } = usePlaybackSpeed();
|
||||
const { synchronizePlayback } = useAudioTrack();
|
||||
const { dismissFullscreenPlayer } = usePlayer();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [resizeMode, setResizeMode] = useState(ResizeMode.CONTAIN);
|
||||
const [hasStartedPlaying, setHasStartedPlaying] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const state = usePlayerStore((state) => state.interface.state);
|
||||
const isIdle = usePlayerStore((state) => state.interface.isIdle);
|
||||
const stream = usePlayerStore((state) => state.interface.currentStream);
|
||||
const selectedAudioTrack = useAudioTrackStore((state) => state.selectedTrack);
|
||||
const videoRef = usePlayerStore((state) => state.videoRef);
|
||||
const setVideoRef = usePlayerStore((state) => state.setVideoRef);
|
||||
const videoSrc = usePlayerStore((state) => state.videoSrc) ?? undefined;
|
||||
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
|
||||
const setStatus = usePlayerStore((state) => state.setStatus);
|
||||
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
|
||||
const toggleAudio = usePlayerStore((state) => state.toggleAudio);
|
||||
const toggleState = usePlayerStore((state) => state.toggleState);
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
const setMeta = usePlayerStore((state) => state.setMeta);
|
||||
const isLocalFile = usePlayerStore((state) => state.isLocalFile);
|
||||
|
||||
const { gestureControls, autoPlay } = usePlayerSettingsStore();
|
||||
const { updateWatchHistory, removeFromWatchHistory, getWatchHistoryItem } =
|
||||
useWatchHistoryStore();
|
||||
|
||||
const updateResizeMode = (newMode: ResizeMode) => {
|
||||
setResizeMode(newMode);
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
const pinchGesture = Gesture.Pinch().onUpdate((e) => {
|
||||
scale.value = e.scale;
|
||||
if (scale.value > 1 && resizeMode !== ResizeMode.COVER) {
|
||||
runOnJS(updateResizeMode)(ResizeMode.COVER);
|
||||
} else if (scale.value <= 1 && resizeMode !== ResizeMode.CONTAIN) {
|
||||
runOnJS(updateResizeMode)(ResizeMode.CONTAIN);
|
||||
}
|
||||
});
|
||||
|
||||
const doubleTapGesture = Gesture.Tap()
|
||||
.enabled(gestureControls && isIdle)
|
||||
.numberOfTaps(2)
|
||||
.onEnd(() => {
|
||||
runOnJS(toggleAudio)();
|
||||
runOnJS(toggleState)();
|
||||
});
|
||||
|
||||
const screenHalfWidth = Dimensions.get("window").width / 2;
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.enabled(gestureControls && isIdle)
|
||||
.onStart((event) => {
|
||||
if (event.x > screenHalfWidth) {
|
||||
runOnJS(setShowVolumeOverlay)(true);
|
||||
} else {
|
||||
runOnJS(setShowBrightnessOverlay)(true);
|
||||
}
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const divisor = 5000;
|
||||
const directionMultiplier = event.velocityY < 0 ? 1 : -1;
|
||||
|
||||
const change = directionMultiplier * Math.abs(event.velocityY / divisor);
|
||||
|
||||
if (event.x > screenHalfWidth) {
|
||||
const newVolume = Math.max(0, Math.min(1, volume.value + change));
|
||||
volume.value = newVolume;
|
||||
} else {
|
||||
const newBrightness = Math.max(
|
||||
0,
|
||||
Math.min(1, brightness.value + change),
|
||||
);
|
||||
brightness.value = newBrightness;
|
||||
runOnJS(handleBrightnessChange)(newBrightness);
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
if (event.x > screenHalfWidth) {
|
||||
runOnJS(setShowVolumeOverlay)(false);
|
||||
} else {
|
||||
runOnJS(setShowBrightnessOverlay)(false);
|
||||
}
|
||||
});
|
||||
|
||||
const composedGesture = Gesture.Race(
|
||||
panGesture,
|
||||
pinchGesture,
|
||||
doubleTapGesture,
|
||||
);
|
||||
|
||||
StatusBar.setStatusBarHidden(true);
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
void NavigationBar.setVisibilityAsync("hidden");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const initializePlayer = async () => {
|
||||
if (videoSrc?.uri && isLocalFile) return;
|
||||
|
||||
if (!stream) {
|
||||
await dismissFullscreenPlayer();
|
||||
return router.back();
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
let url = null;
|
||||
|
||||
if (stream.type === "hls") {
|
||||
url = stream.playlist;
|
||||
}
|
||||
|
||||
if (stream.type === "file") {
|
||||
const highestQuality = findHighestQuality(stream);
|
||||
url = highestQuality ? stream.qualities[highestQuality]?.url : null;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
await dismissFullscreenPlayer();
|
||||
return router.back();
|
||||
}
|
||||
|
||||
setVideoSrc({
|
||||
uri: url,
|
||||
headers: {
|
||||
...stream.preferredHeaders,
|
||||
...stream.headers,
|
||||
},
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
void initializePlayer();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasStartedPlaying) {
|
||||
router.back();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
return () => {
|
||||
if (meta) {
|
||||
const item = convertMetaToItemData(meta);
|
||||
const scrapeMedia = convertMetaToScrapeMedia(meta);
|
||||
updateWatchHistory(
|
||||
item,
|
||||
scrapeMedia,
|
||||
videoRef?.props.positionMillis ?? 0,
|
||||
);
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
void synchronizePlayback();
|
||||
};
|
||||
}, [
|
||||
isLocalFile,
|
||||
dismissFullscreenPlayer,
|
||||
hasStartedPlaying,
|
||||
meta,
|
||||
router,
|
||||
selectedAudioTrack,
|
||||
setVideoSrc,
|
||||
stream,
|
||||
synchronizePlayback,
|
||||
updateWatchHistory,
|
||||
videoRef?.props.positionMillis,
|
||||
videoSrc?.uri,
|
||||
]);
|
||||
|
||||
const onVideoLoadStart = () => {
|
||||
setIsLoading(true);
|
||||
};
|
||||
|
||||
const onReadyForDisplay = () => {
|
||||
setIsLoading(false);
|
||||
setHasStartedPlaying(true);
|
||||
if (videoRef) {
|
||||
void videoRef.setRateAsync(currentSpeed, true);
|
||||
|
||||
if (meta) {
|
||||
const media = convertMetaToScrapeMedia(meta);
|
||||
const watchHistoryItem = getWatchHistoryItem(media);
|
||||
|
||||
if (watchHistoryItem) {
|
||||
void videoRef.setPositionAsync(watchHistoryItem.positionMillis);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onPlaybackStatusUpdate = async (status: AVPlaybackStatus) => {
|
||||
setStatus(status);
|
||||
if (meta && status.isLoaded && status.didJustFinish) {
|
||||
const item = convertMetaToItemData(meta);
|
||||
removeFromWatchHistory(item);
|
||||
}
|
||||
if (
|
||||
status.isLoaded &&
|
||||
status.didJustFinish &&
|
||||
!status.isLooping &&
|
||||
autoPlay
|
||||
) {
|
||||
if (meta?.type !== "show") return;
|
||||
const nextEpisodeMeta = await getNextEpisode(meta);
|
||||
if (!nextEpisodeMeta) return;
|
||||
setMeta(nextEpisodeMeta);
|
||||
const media = convertMetaToScrapeMedia(nextEpisodeMeta);
|
||||
|
||||
router.replace({
|
||||
pathname: "/videoPlayer",
|
||||
params: { media: JSON.stringify(media) },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<View
|
||||
flex={1}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="black"
|
||||
>
|
||||
<Video
|
||||
ref={setVideoRef}
|
||||
source={videoSrc}
|
||||
shouldPlay={state === "playing"}
|
||||
resizeMode={resizeMode}
|
||||
volume={volume.value}
|
||||
rate={currentSpeed}
|
||||
onLoadStart={onVideoLoadStart}
|
||||
onReadyForDisplay={onReadyForDisplay}
|
||||
onPlaybackStatusUpdate={onPlaybackStatusUpdate}
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
...(!isIdle && {
|
||||
opacity: 0.7,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
onTouchStart={() => setIsIdle(!isIdle)}
|
||||
/>
|
||||
<View
|
||||
height="100%"
|
||||
width="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{isLoading && (
|
||||
<Spinner
|
||||
size="large"
|
||||
color="$loadingIndicator"
|
||||
position="absolute"
|
||||
/>
|
||||
)}
|
||||
<ControlsOverlay isLoading={isLoading} />
|
||||
</View>
|
||||
{showVolumeOverlay && <GestureOverlay value={volume} type="volume" />}
|
||||
{showBrightnessOverlay && (
|
||||
<GestureOverlay value={brightness} type="brightness" />
|
||||
)}
|
||||
<CaptionRenderer />
|
||||
</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
function GestureOverlay(props: {
|
||||
value: SharedValue<number>;
|
||||
type: "brightness" | "volume";
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
height: `${props.value.value * 100}%`,
|
||||
borderTopLeftRadius: props.value.value >= 0.98 ? 44 : 0,
|
||||
borderTopRightRadius: props.value.value >= 0.98 ? 44 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
position="absolute"
|
||||
left={props.type === "brightness" ? insets.left + 20 : undefined}
|
||||
right={props.type === "volume" ? insets.right + 20 : undefined}
|
||||
borderRadius="$4"
|
||||
gap={8}
|
||||
height="50%"
|
||||
>
|
||||
<Feather
|
||||
size={24}
|
||||
color="white"
|
||||
style={{
|
||||
bottom: 20,
|
||||
}}
|
||||
name={props.type === "brightness" ? "sun" : "volume-2"}
|
||||
/>
|
||||
<View
|
||||
width={14}
|
||||
backgroundColor={theme.progressBackground}
|
||||
justifyContent="flex-end"
|
||||
borderRadius="$4"
|
||||
left={4}
|
||||
bottom={20}
|
||||
height="100%"
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: "100%",
|
||||
backgroundColor: theme.progressFilled.val,
|
||||
borderBottomRightRadius: 44,
|
||||
borderBottomLeftRadius: 44,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
176
apps/expo/src/components/player/VideoSlider.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
HandlerStateChangeEvent,
|
||||
PanGestureHandlerGestureEvent,
|
||||
TapGestureHandlerEventPayload,
|
||||
} from "react-native-gesture-handler";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Dimensions } from "react-native";
|
||||
import {
|
||||
PanGestureHandler,
|
||||
State,
|
||||
TapGestureHandler,
|
||||
} from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedGestureHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useTheme, View } from "tamagui";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
|
||||
const clamp = (value: number, lowerBound: number, upperBound: number) => {
|
||||
"worklet";
|
||||
return Math.min(Math.max(lowerBound, value), upperBound);
|
||||
};
|
||||
|
||||
interface VideoSliderProps {
|
||||
onSlidingComplete?: (value: number) => void;
|
||||
}
|
||||
|
||||
const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
|
||||
const theme = useTheme();
|
||||
const tapRef = useRef<TapGestureHandler>(null);
|
||||
const panRef = useRef<PanGestureHandler>(null);
|
||||
const status = usePlayerStore((state) => state.status);
|
||||
const setIsIdle = usePlayerStore((state) => state.setIsIdle);
|
||||
|
||||
const width = Dimensions.get("screen").width - 120;
|
||||
const knobSize_ = 20;
|
||||
const trackSize_ = 8;
|
||||
const minimumValue = 0;
|
||||
const maximumValue = status?.isLoaded ? status.durationMillis! : 0;
|
||||
const value = status?.isLoaded ? status.positionMillis : 0;
|
||||
|
||||
const valueToX = (v: number) => {
|
||||
if (maximumValue === minimumValue) return 0;
|
||||
return (width * (v - minimumValue)) / (maximumValue - minimumValue);
|
||||
};
|
||||
const xToValue = (x: number) => {
|
||||
"worklet";
|
||||
if (maximumValue === minimumValue) return minimumValue;
|
||||
return (x / width) * (maximumValue - minimumValue) + minimumValue;
|
||||
};
|
||||
const valueX = valueToX(value);
|
||||
const translateX = useSharedValue(valueToX(value));
|
||||
const isDragging = useSharedValue(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging.value) {
|
||||
translateX.value = clamp(valueX, 0, width - knobSize_);
|
||||
}
|
||||
}, [valueX, isDragging.value, translateX, width]);
|
||||
|
||||
const _onSlidingComplete = (xValue: number) => {
|
||||
"worklet";
|
||||
if (onSlidingComplete) runOnJS(onSlidingComplete)(xToValue(xValue));
|
||||
};
|
||||
|
||||
const _onActive = (value: number) => {
|
||||
"worklet";
|
||||
isDragging.value = true;
|
||||
translateX.value = clamp(value, 0, width - knobSize_);
|
||||
runOnJS(setIsIdle)(false);
|
||||
};
|
||||
|
||||
const _onEnd = () => {
|
||||
"worklet";
|
||||
isDragging.value = false;
|
||||
_onSlidingComplete(translateX.value);
|
||||
};
|
||||
|
||||
const onGestureEvent = useAnimatedGestureHandler<
|
||||
PanGestureHandlerGestureEvent,
|
||||
{ offsetX: number }
|
||||
>({
|
||||
onStart: (_, ctx) => (ctx.offsetX = translateX.value),
|
||||
onActive: (event, ctx) => _onActive(event.translationX + ctx.offsetX),
|
||||
onEnd: _onEnd,
|
||||
onCancel: _onEnd,
|
||||
onFinish: _onEnd,
|
||||
});
|
||||
|
||||
const onTapEvent = (
|
||||
event: HandlerStateChangeEvent<TapGestureHandlerEventPayload>,
|
||||
) => {
|
||||
if (event.nativeEvent.state === State.ACTIVE) {
|
||||
_onActive(event.nativeEvent.x);
|
||||
_onSlidingComplete(event.nativeEvent.x);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollTranslationStyle = useAnimatedStyle(() => {
|
||||
return { transform: [{ translateX: translateX.value }] };
|
||||
});
|
||||
|
||||
const progressStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
width: translateX.value + knobSize_,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<TapGestureHandler
|
||||
ref={tapRef}
|
||||
onHandlerStateChange={onTapEvent}
|
||||
simultaneousHandlers={panRef}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: knobSize_,
|
||||
width,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
height: trackSize_,
|
||||
borderRadius: trackSize_,
|
||||
backgroundColor: theme.videoSlider.val,
|
||||
width,
|
||||
justifyContent: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
height: trackSize_,
|
||||
backgroundColor: theme.videoSliderFilled.val,
|
||||
borderRadius: trackSize_ / 2,
|
||||
},
|
||||
progressStyle,
|
||||
]}
|
||||
/>
|
||||
<PanGestureHandler
|
||||
ref={panRef}
|
||||
onGestureEvent={onGestureEvent}
|
||||
simultaneousHandlers={tapRef}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: knobSize_,
|
||||
width: knobSize_,
|
||||
borderRadius: knobSize_ / 2,
|
||||
backgroundColor: theme.videoSliderFilled.val,
|
||||
},
|
||||
scrollTranslationStyle,
|
||||
]}
|
||||
/>
|
||||
</PanGestureHandler>
|
||||
</View>
|
||||
</View>
|
||||
</TapGestureHandler>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoSlider;
|
157
apps/expo/src/components/player/settings/Sheet.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { SheetProps, ViewProps } from "tamagui";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
ScrollView,
|
||||
Separator,
|
||||
Sheet,
|
||||
Spinner,
|
||||
styled,
|
||||
Text,
|
||||
View,
|
||||
} from "tamagui";
|
||||
|
||||
const PlayerText = styled(Text, {
|
||||
color: "$playerSettingsUnactiveText",
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
});
|
||||
|
||||
function SettingsSheet(props: SheetProps) {
|
||||
return (
|
||||
<Sheet
|
||||
snapPoints={[90]}
|
||||
dismissOnSnapToBottom
|
||||
modal
|
||||
animation="spring"
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSheetOverlay() {
|
||||
return (
|
||||
<Sheet.Overlay
|
||||
animation="lazy"
|
||||
backgroundColor="rgba(0, 0, 0, 0.7)"
|
||||
enterStyle={{ opacity: 0 }}
|
||||
exitStyle={{ opacity: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSheetHandle() {
|
||||
return <Sheet.Handle backgroundColor="$sheetHandle" />;
|
||||
}
|
||||
|
||||
function SettingsSheetFrame({
|
||||
children,
|
||||
isLoading,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ flex: 1 }} backgroundColor="black">
|
||||
<Sheet.Frame
|
||||
backgroundColor="$playerSettingsBackground"
|
||||
padding="$5"
|
||||
gap="$4"
|
||||
>
|
||||
{isLoading && (
|
||||
<Spinner
|
||||
size="large"
|
||||
color="$loadingIndicator"
|
||||
style={{
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && children}
|
||||
</Sheet.Frame>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsHeader({
|
||||
icon,
|
||||
title,
|
||||
rightButton,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
rightButton?: React.ReactNode;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style={{ paddingLeft: insets.left, paddingRight: insets.right }}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
gap="$4"
|
||||
>
|
||||
{icon}
|
||||
<PlayerText flexGrow={1}>{title}</PlayerText>
|
||||
{rightButton}
|
||||
</View>
|
||||
<Separator />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsContent({
|
||||
isScroll = true,
|
||||
children,
|
||||
}: {
|
||||
isScroll?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ViewDisplay = isScroll ? ScrollView : View;
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ViewDisplay
|
||||
style={{ paddingLeft: insets.left, paddingRight: insets.right }}
|
||||
contentContainerStyle={{
|
||||
gap: "$4",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ViewDisplay>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsItem({
|
||||
iconLeft,
|
||||
iconRight,
|
||||
title,
|
||||
...props
|
||||
}: ViewProps & {
|
||||
iconLeft?: React.ReactNode;
|
||||
iconRight?: React.ReactNode;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<View flexDirection="row" gap="$4" alignItems="center" {...props}>
|
||||
{iconLeft}
|
||||
<PlayerText flexGrow={1} fontSize={16} fontWeight="700">
|
||||
{title}
|
||||
</PlayerText>
|
||||
{iconRight}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const Settings = {
|
||||
Sheet: SettingsSheet,
|
||||
SheetOverlay: SettingsSheetOverlay,
|
||||
SheetHandle: SettingsSheetHandle,
|
||||
SheetFrame: SettingsSheetFrame,
|
||||
Header: SettingsHeader,
|
||||
Content: SettingsContent,
|
||||
Text: PlayerText,
|
||||
Item: SettingsItem,
|
||||
};
|
18
apps/expo/src/components/player/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const mapMillisecondsToTime = (milliseconds: number): string => {
|
||||
const hours = Math.floor(milliseconds / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((milliseconds % (1000 * 60)) / 1000);
|
||||
|
||||
const components: string[] = [];
|
||||
|
||||
if (hours > 0) {
|
||||
components.push(hours.toString().padStart(2, "0"));
|
||||
}
|
||||
|
||||
components.push(minutes.toString().padStart(2, "0"));
|
||||
components.push(seconds.toString().padStart(2, "0"));
|
||||
|
||||
const formattedTime = components.join(":");
|
||||
|
||||
return formattedTime;
|
||||
};
|
28
apps/expo/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Button, styled } from "tamagui";
|
||||
|
||||
export const MWButton = styled(Button, {
|
||||
variants: {
|
||||
type: {
|
||||
primary: {
|
||||
backgroundColor: "$buttonPrimaryBackground",
|
||||
color: "$buttonPrimaryText",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: "$buttonSecondaryBackground",
|
||||
color: "$buttonSecondaryText",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
purple: {
|
||||
backgroundColor: "$buttonPurpleBackground",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
cancel: {
|
||||
backgroundColor: "$buttonCancelBackground",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
},
|
||||
} as const,
|
||||
});
|
26
apps/expo/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Input, styled } from "tamagui";
|
||||
|
||||
export const MWInput = styled(Input, {
|
||||
fontWeight: "$semibold",
|
||||
|
||||
variants: {
|
||||
type: {
|
||||
default: {
|
||||
backgroundColor: "$inputBackground",
|
||||
color: "$inputText",
|
||||
placeholderTextColor: "$placeHolderText",
|
||||
borderColor: "$inputBorder",
|
||||
outlineStyle: "none",
|
||||
},
|
||||
search: {
|
||||
backgroundColor: "$searchBackground",
|
||||
borderColor: "$colorTransparent",
|
||||
placeholderTextColor: "$searchPlaceholder",
|
||||
outlineStyle: "none",
|
||||
focusStyle: {
|
||||
borderColor: "$colorTransparent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
14
apps/expo/src/components/ui/Progress.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Progress, styled, withStaticProperties } from "tamagui";
|
||||
|
||||
const MWProgressFrame = styled(Progress, {
|
||||
backgroundColor: "$progressBackground",
|
||||
});
|
||||
|
||||
const MWProgressIndicator = styled(Progress.Indicator, {
|
||||
backgroundColor: "$progressFilled",
|
||||
animation: "bounce",
|
||||
});
|
||||
|
||||
export const MWProgress = withStaticProperties(MWProgressFrame, {
|
||||
Indicator: MWProgressIndicator,
|
||||
});
|
52
apps/expo/src/components/ui/Searchbar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Input } from "tamagui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FontAwesome5 } from "@expo/vector-icons";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { useTheme, View } from "tamagui";
|
||||
|
||||
import { MWInput } from "./Input";
|
||||
|
||||
export function SearchBar({
|
||||
onSearchChange,
|
||||
}: {
|
||||
onSearchChange: (text: string) => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const pageIsFocused = useIsFocused();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const inputRef = useRef<Input>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageIsFocused) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [pageIsFocused]);
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setKeyword(text);
|
||||
onSearchChange(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
backgroundColor={theme.searchBackground}
|
||||
>
|
||||
<View width={48} alignItems="center" justifyContent="center">
|
||||
<FontAwesome5 name="search" size={18} color={theme.searchIcon.val} />
|
||||
</View>
|
||||
<MWInput
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChangeText={handleChange}
|
||||
ref={inputRef}
|
||||
placeholder="What are you looking for?"
|
||||
width="75%"
|
||||
backgroundColor={theme.searchBackground}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
38
apps/expo/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Select, styled, withStaticProperties } from "tamagui";
|
||||
|
||||
const MWSelectFrame = styled(Select, {
|
||||
variants: {
|
||||
type: {
|
||||
default: {
|
||||
backgroundColor: "$inputBackground",
|
||||
color: "$inputText",
|
||||
borderColor: "$inputBorder",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const MWSelectTrigger = styled(Select.Trigger, {
|
||||
variants: {
|
||||
type: {
|
||||
default: {
|
||||
backgroundColor: "$inputBackground",
|
||||
color: "$inputText",
|
||||
placeholderTextColor: "$inputPlaceholderText",
|
||||
borderColor: "$inputBorder",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const MWSelect = withStaticProperties(MWSelectFrame, {
|
||||
Trigger: MWSelectTrigger,
|
||||
});
|
||||
|
||||
export { MWSelect };
|
14
apps/expo/src/components/ui/Separator.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Separator, styled } from "tamagui";
|
||||
|
||||
export const MWSeparator = styled(Separator, {
|
||||
variants: {
|
||||
type: {
|
||||
settings: {
|
||||
borderColor: "$shade300",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: "settings",
|
||||
},
|
||||
});
|
27
apps/expo/src/components/ui/Switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { SwitchProps, SwitchThumbProps } from "tamagui";
|
||||
import { Switch, useTheme } from "tamagui";
|
||||
|
||||
const MWSwitch = (props: SwitchProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Switch
|
||||
native
|
||||
nativeProps={{
|
||||
trackColor: {
|
||||
true: theme.switchActiveTrackColor.val,
|
||||
false: theme.switchInactiveTrackColor.val,
|
||||
},
|
||||
thumbColor: theme.switchThumbColor.val,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const MWSwitchThumb = (props: SwitchThumbProps) => {
|
||||
return <Switch.Thumb animation="bounce" {...props} />;
|
||||
};
|
||||
|
||||
MWSwitch.Thumb = MWSwitchThumb;
|
||||
|
||||
export { MWSwitch };
|
36
apps/expo/src/components/ui/Text.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { TextProps } from "react-native";
|
||||
import type { AnimatedProps } from "react-native-reanimated";
|
||||
import { useEffect } from "react";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
export const FlashingText = (
|
||||
props: AnimatedProps<TextProps> & {
|
||||
isInProgress: boolean;
|
||||
},
|
||||
) => {
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.isInProgress) {
|
||||
opacity.value = withRepeat(
|
||||
withTiming(1, { duration: 1000, easing: Easing.ease }),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
opacity.value = 1;
|
||||
}
|
||||
}, [props.isInProgress, opacity]);
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return <Animated.Text {...props} style={[props.style, style]} />;
|
||||
};
|
2
apps/expo/src/constants/core.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const DISCORD_LINK = "https://movie-web.github.io/links/discord";
|
||||
export const GITHUB_LINK = "https://github.com/movie-web";
|
88
apps/expo/src/hooks/player/useAudioTrack.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Video } from "expo-av";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Audio } from "expo-av";
|
||||
|
||||
import type { Stream } from "@movie-web/provider-utils";
|
||||
|
||||
import type { AudioTrack } from "~/components/player/AudioTrackSelector";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
|
||||
export const useAudioTrack = () => {
|
||||
const videoRef = usePlayerStore((state) => state.videoRef);
|
||||
const audioObject = usePlayerStore((state) => state.audioObject);
|
||||
const currentAudioTrack = usePlayerStore((state) => state.currentAudioTrack);
|
||||
const setAudioObject = usePlayerStore((state) => state.setAudioObject);
|
||||
const setCurrentAudioTrack = usePlayerStore(
|
||||
(state) => state.setCurrentAudioTrack,
|
||||
);
|
||||
|
||||
const synchronizePlayback = useCallback(
|
||||
async (selectedAudioTrack?: AudioTrack, stream?: Stream) => {
|
||||
if (selectedAudioTrack && stream) {
|
||||
if (audioObject) {
|
||||
await audioObject.unloadAsync();
|
||||
}
|
||||
|
||||
const createAudioAsyncWithTimeout = (uri: string, timeout = 5000) => {
|
||||
return new Promise<Audio.Sound | undefined>((resolve, reject) => {
|
||||
Audio.Sound.createAsync({
|
||||
uri,
|
||||
headers: {
|
||||
...stream.headers,
|
||||
...stream.preferredHeaders,
|
||||
},
|
||||
})
|
||||
.then((value) => resolve(value.sound))
|
||||
.catch(reject);
|
||||
|
||||
setTimeout(() => {
|
||||
reject(new Error("Timeout: Audio loading took too long"));
|
||||
}, timeout);
|
||||
});
|
||||
};
|
||||
try {
|
||||
const sound = await createAudioAsyncWithTimeout(
|
||||
selectedAudioTrack.uri,
|
||||
);
|
||||
if (!sound) return;
|
||||
setAudioObject(sound);
|
||||
setCurrentAudioTrack(selectedAudioTrack);
|
||||
} catch (error) {
|
||||
console.error("Error loading audio track:", error);
|
||||
}
|
||||
} else {
|
||||
if (audioObject) {
|
||||
await audioObject.unloadAsync();
|
||||
setAudioObject(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[audioObject, setAudioObject, setCurrentAudioTrack],
|
||||
);
|
||||
|
||||
const synchronizeAudioWithVideo = async (
|
||||
videoRef: Video | null,
|
||||
audioObject: Audio.Sound | null,
|
||||
selectedAudioTrack?: AudioTrack,
|
||||
): Promise<void> => {
|
||||
if (videoRef && audioObject) {
|
||||
const videoStatus = await videoRef.getStatusAsync();
|
||||
|
||||
if (selectedAudioTrack && videoStatus.isLoaded) {
|
||||
await videoRef.setIsMutedAsync(true);
|
||||
await audioObject.playAsync();
|
||||
await audioObject.setPositionAsync(videoStatus.positionMillis + 2000);
|
||||
} else {
|
||||
await videoRef.setIsMutedAsync(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (audioObject && currentAudioTrack) {
|
||||
void synchronizeAudioWithVideo(videoRef, audioObject, currentAudioTrack);
|
||||
}
|
||||
}, [audioObject, videoRef, currentAudioTrack]);
|
||||
|
||||
return { synchronizePlayback };
|
||||
};
|
24
apps/expo/src/hooks/player/useBrightness.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import * as Brightness from "expo-brightness";
|
||||
|
||||
export const useBrightness = () => {
|
||||
const [showBrightnessOverlay, setShowBrightnessOverlay] = useState(false);
|
||||
|
||||
const brightness = useSharedValue(0.5);
|
||||
|
||||
const handleBrightnessChange = useCallback(async (newValue: number) => {
|
||||
try {
|
||||
await Brightness.setBrightnessAsync(newValue);
|
||||
} catch (error) {
|
||||
console.error("Failed to set brightness:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showBrightnessOverlay,
|
||||
setShowBrightnessOverlay,
|
||||
brightness,
|
||||
handleBrightnessChange,
|
||||
} as const;
|
||||
};
|
58
apps/expo/src/hooks/player/useMeta.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { transformSearchResultToScrapeMedia } from "@movie-web/provider-utils";
|
||||
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
|
||||
export const useMeta = () => {
|
||||
const meta = usePlayerStore((state) => state.meta);
|
||||
const setMeta = usePlayerStore((state) => state.setMeta);
|
||||
|
||||
const convertIdToMeta = useCallback(
|
||||
async (id: string, type: "movie" | "tv") => {
|
||||
const media = await fetchMediaDetails(id, type);
|
||||
if (!media) return;
|
||||
const scrapeMedia = transformSearchResultToScrapeMedia(
|
||||
media.type,
|
||||
media.result,
|
||||
meta?.season?.number,
|
||||
meta?.episode?.number,
|
||||
);
|
||||
let seasonData = null;
|
||||
if (scrapeMedia.type === "show") {
|
||||
seasonData = await fetchSeasonDetails(
|
||||
scrapeMedia.tmdbId,
|
||||
scrapeMedia.season.number,
|
||||
);
|
||||
}
|
||||
const m = {
|
||||
...scrapeMedia,
|
||||
poster: media.result.poster_path,
|
||||
...("season" in scrapeMedia
|
||||
? {
|
||||
season: {
|
||||
number: scrapeMedia.season.number,
|
||||
tmdbId: scrapeMedia.tmdbId,
|
||||
},
|
||||
episode: {
|
||||
number: scrapeMedia.episode.number,
|
||||
tmdbId: scrapeMedia.episode.tmdbId,
|
||||
},
|
||||
episodes:
|
||||
seasonData?.episodes.map((e) => ({
|
||||
tmdbId: e.id.toString(),
|
||||
number: e.episode_number,
|
||||
name: e.name,
|
||||
})) ?? [],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
setMeta(m);
|
||||
return m;
|
||||
},
|
||||
[meta?.episode?.number, meta?.season?.number, setMeta],
|
||||
);
|
||||
|
||||
return { convertIdToMeta };
|
||||
};
|
24
apps/expo/src/hooks/player/usePlaybackSpeed.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
|
||||
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
|
||||
export const usePlaybackSpeed = () => {
|
||||
const videoRef = usePlayerStore((state) => state.videoRef);
|
||||
|
||||
const changePlaybackSpeed = useCallback(
|
||||
async (newValue: number) => {
|
||||
if (videoRef) {
|
||||
await videoRef.setRateAsync(newValue, true);
|
||||
}
|
||||
},
|
||||
[videoRef],
|
||||
);
|
||||
|
||||
return {
|
||||
speeds,
|
||||
currentSpeed: videoRef?.props.rate ?? 1,
|
||||
changePlaybackSpeed,
|
||||
} as const;
|
||||
};
|
21
apps/expo/src/hooks/player/usePlayer.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useCallback } from "react";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
|
||||
export const usePlayer = () => {
|
||||
const presentFullscreenPlayer = useCallback(async () => {
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const dismissFullscreenPlayer = useCallback(async () => {
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
presentFullscreenPlayer,
|
||||
dismissFullscreenPlayer,
|
||||
} as const;
|
||||
};
|