forked from Ninjalama/streamyfin_mirror
Compare commits
394 Commits
refactor/s
...
feat/conte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90930d478c | ||
|
|
8608ad02f7 | ||
|
|
030947fc38 | ||
|
|
9b18188b32 | ||
|
|
d86853dec9 | ||
|
|
0750acdc13 | ||
|
|
d8231f5b80 | ||
|
|
41d17499bb | ||
|
|
60f1217cae | ||
|
|
834de10e34 | ||
|
|
51f17f983d | ||
|
|
ba4a2c0b79 | ||
|
|
a32eb710ec | ||
|
|
cb05da782a | ||
|
|
5a680a4392 | ||
|
|
8a44d2ff15 | ||
|
|
f3f260625f | ||
|
|
6908620f4e | ||
|
|
9932266203 | ||
|
|
cb2268e39c | ||
|
|
bf9be278d3 | ||
|
|
584fcc09d6 | ||
|
|
7a26b5004b | ||
|
|
ae92692ea0 | ||
|
|
92e4b3b8cf | ||
|
|
127ec1391b | ||
|
|
0ac4f826bc | ||
|
|
6190f2e602 | ||
|
|
24fdd071af | ||
|
|
be3122caac | ||
|
|
39a220bbed | ||
|
|
e3bdbb5cbd | ||
|
|
b6ad05d980 | ||
|
|
0360b5cbd5 | ||
|
|
a9b1d9fb0a | ||
|
|
4291ef55b9 | ||
|
|
655060fb40 | ||
|
|
0e29b8b671 | ||
|
|
72f64c71dd | ||
|
|
ddfd9f6ce3 | ||
|
|
67fb339d40 | ||
|
|
9e0a7f047c | ||
|
|
aab806bbf4 | ||
|
|
4a53b20618 | ||
|
|
45299a5c5d | ||
|
|
65ad4effca | ||
|
|
35fcb5ca0c | ||
|
|
5dc0066370 | ||
|
|
3fb20a8ca2 | ||
|
|
180ed54fed | ||
|
|
72859b4ae3 | ||
|
|
bfe96edb29 | ||
|
|
46f4acdad0 | ||
|
|
da1aa9f48c | ||
|
|
1d0d99c79b | ||
|
|
33a6295b20 | ||
|
|
72cc381087 | ||
|
|
c4bfaf2d56 | ||
|
|
487ac398e5 | ||
|
|
84fd0edc49 | ||
|
|
0e1583c440 | ||
|
|
6459e5f323 | ||
|
|
319e1fd53f | ||
|
|
93bd817eaf | ||
|
|
d9f21e6824 | ||
|
|
d287f5d082 | ||
|
|
ecd2fa386e | ||
|
|
7c022bbaff | ||
|
|
5d79ee34cf | ||
|
|
b0adad8dc4 | ||
|
|
c3d3f538d7 | ||
|
|
6b6dedf303 | ||
|
|
8d22e4c075 | ||
|
|
4dff26e8c3 | ||
|
|
ee2edda507 | ||
|
|
9e6a8424db | ||
|
|
d37ecc1bef | ||
|
|
e70fd3ee45 | ||
|
|
16e93513e2 | ||
|
|
b0c506f85d | ||
|
|
b762aff6e2 | ||
|
|
75639c4424 | ||
|
|
4606ce1834 | ||
|
|
44bde8f41e | ||
|
|
828edad749 | ||
|
|
f842c8a41f | ||
|
|
4d38573973 | ||
|
|
785e3b6859 | ||
|
|
40b3304f9b | ||
|
|
abf1b343cd | ||
|
|
e427802aae | ||
|
|
684e671750 | ||
|
|
5e9b28f2eb | ||
|
|
1d4c56265f | ||
|
|
1102df8384 | ||
|
|
15073f47db | ||
|
|
15f32bca6c | ||
|
|
108c5f9bab | ||
|
|
24d781050f | ||
|
|
353ebf3b0c | ||
|
|
c8b16f947d | ||
|
|
bd24f59199 | ||
|
|
a6b49c42cf | ||
|
|
5afb677b3a | ||
|
|
65d3da155f | ||
|
|
d616574232 | ||
|
|
b8b083abe2 | ||
|
|
49a1bffcf5 | ||
|
|
cb6c716830 | ||
|
|
a725af114c | ||
|
|
5b290fd667 | ||
|
|
de4f60f564 | ||
|
|
a4cd3ea600 | ||
|
|
3db12bd76a | ||
|
|
26305c2983 | ||
|
|
9c02fa2e72 | ||
|
|
b08ec474a4 | ||
|
|
416fb24ac0 | ||
|
|
0d2b15e5af | ||
|
|
ef036cb362 | ||
|
|
006e457d23 | ||
|
|
832a717585 | ||
|
|
39f86a9eb1 | ||
|
|
38445c6959 | ||
|
|
24320541c7 | ||
|
|
ee4e9fe347 | ||
|
|
6d43b34f66 | ||
|
|
63cf7eb622 | ||
|
|
32130f1a9c | ||
|
|
7f458f2f0b | ||
|
|
6ec6c6daa0 | ||
|
|
02a48fd958 | ||
|
|
04c4dfd13a | ||
|
|
40bdb10653 | ||
|
|
f16c486bfb | ||
|
|
19fc00e314 | ||
|
|
c51965016c | ||
|
|
3bcf73f0dd | ||
|
|
1ecef4be67 | ||
|
|
387525f9c3 | ||
|
|
cf182d8473 | ||
|
|
f0e3321a16 | ||
|
|
96c76e2b08 | ||
|
|
aaa07d93cf | ||
|
|
0716bba6ec | ||
|
|
15476f3686 | ||
|
|
97cf9185d3 | ||
|
|
c11ad17ca5 | ||
|
|
b0d563bc48 | ||
|
|
909fc84ec0 | ||
|
|
0400597061 | ||
|
|
b44a5fbbba | ||
|
|
a5f6ba27b1 | ||
|
|
ece1b8f2b9 | ||
|
|
beb6702112 | ||
|
|
98c0ed4ad5 | ||
|
|
b3f471bfa6 | ||
|
|
1a10f0debf | ||
|
|
ac266c6956 | ||
|
|
b23a50914c | ||
|
|
5c4a419d22 | ||
|
|
3d034864f9 | ||
|
|
ea183c426b | ||
|
|
92be991cf7 | ||
|
|
b73c29221a | ||
|
|
880a739dd4 | ||
|
|
69ffdc2ddf | ||
|
|
d686bd8c7b | ||
|
|
c8a60e735b | ||
|
|
05f7574e60 | ||
|
|
11b880863c | ||
|
|
aec172d8f5 | ||
|
|
7b52528d72 | ||
|
|
5fd1d9080e | ||
|
|
5cc0f381fa | ||
|
|
0f547deb39 | ||
|
|
5aeb80348a | ||
|
|
1dfc0ac762 | ||
|
|
2b8aee442a | ||
|
|
3e45adfeb5 | ||
|
|
b41363d347 | ||
|
|
2d5a27c015 | ||
|
|
b5c6403e2d | ||
|
|
7eb7d17fa9 | ||
|
|
3d8875208f | ||
|
|
e4cfb52dab | ||
|
|
879e79cc47 | ||
|
|
b9abe3e7f7 | ||
|
|
383062ac0d | ||
|
|
3a507b6d1b | ||
|
|
500005afa8 | ||
|
|
b638743497 | ||
|
|
73aae1d260 | ||
|
|
b84e95dc54 | ||
|
|
5292d89303 | ||
|
|
acd14279f4 | ||
|
|
945d553cae | ||
|
|
c33890fb38 | ||
|
|
c718f53109 | ||
|
|
18552bf622 | ||
|
|
ec5c367438 | ||
|
|
ba38fe6c03 | ||
|
|
a37da8f667 | ||
|
|
8b0b3d8abc | ||
|
|
d113729b6f | ||
|
|
e6ea5d13d4 | ||
|
|
c911a3c38a | ||
|
|
a1a895815a | ||
|
|
ea06efb82e | ||
|
|
8a655c04b2 | ||
|
|
2db4effef5 | ||
|
|
88a3bdd891 | ||
|
|
6df20f516c | ||
|
|
1fdf45daa7 | ||
|
|
e8f4ee2264 | ||
|
|
81d4e778e3 | ||
|
|
025ce45e33 | ||
|
|
4f72cacbc0 | ||
|
|
8c909e17bd | ||
|
|
98fbf71ff8 | ||
|
|
bf0c8a8007 | ||
|
|
44e5436c3b | ||
|
|
d22f047f2b | ||
|
|
7f9dd4e14e | ||
|
|
e82890d7ff | ||
|
|
0054095b20 | ||
|
|
d218d0b1c2 | ||
|
|
93d117640a | ||
|
|
d4009040d8 | ||
|
|
3d8e4a07ce | ||
|
|
726301aca8 | ||
|
|
887ef10265 | ||
|
|
d47dd633c7 | ||
|
|
835484b367 | ||
|
|
335765993d | ||
|
|
734772fb92 | ||
|
|
56b37a1ec1 | ||
|
|
6a50eb9044 | ||
|
|
3dee8ba2e3 | ||
|
|
dc73677876 | ||
|
|
0633d60186 | ||
|
|
55f8af7069 | ||
|
|
02f4e4a16b | ||
|
|
c56b80889f | ||
|
|
ad2bfd8f28 | ||
|
|
0418cffba1 | ||
|
|
6a29a10d82 | ||
|
|
c5077953a8 | ||
|
|
0e720aa8cf | ||
|
|
4699ee9c18 | ||
|
|
a7dd74e7ab | ||
|
|
2a52499a75 | ||
|
|
a3f8087ccc | ||
|
|
73acca6c21 | ||
|
|
f2367d3f68 | ||
|
|
868c046cd2 | ||
|
|
52b5b2875c | ||
|
|
1aed133a67 | ||
|
|
f127ee2976 | ||
|
|
72410d2729 | ||
|
|
dcf59ac18e | ||
|
|
6b7bbf716c | ||
|
|
6224f8b92d | ||
|
|
3843bf1fcd | ||
|
|
5c44db183a | ||
|
|
2350f4e294 | ||
|
|
7ce3bc6e92 | ||
|
|
21fbe1adae | ||
|
|
cef1327fcb | ||
|
|
a5677aae86 | ||
|
|
44a7ec238f | ||
|
|
34d7ab5f1e | ||
|
|
991f58cf73 | ||
|
|
558480ea9d | ||
|
|
6b751cf154 | ||
|
|
e010c8229c | ||
|
|
128c369e55 | ||
|
|
0b0afb448d | ||
|
|
3d20b7956f | ||
|
|
1fdf7ca42f | ||
|
|
865fbdf834 | ||
|
|
8ed81fbe23 | ||
|
|
817e2b3d85 | ||
|
|
fff880e708 | ||
|
|
f2bcd2c675 | ||
|
|
00a296cee6 | ||
|
|
33b94105c2 | ||
|
|
a23e370deb | ||
|
|
d95833335e | ||
|
|
5e91f45e3d | ||
|
|
b8111babd2 | ||
|
|
87092fed48 | ||
|
|
65a6ab9972 | ||
|
|
8943708ff5 | ||
|
|
c0b2579fdd | ||
|
|
272b8b914f | ||
|
|
4eb7d0f151 | ||
|
|
229670e829 | ||
|
|
341a0f21d7 | ||
|
|
91b4e403e6 | ||
|
|
152d3a9c1c | ||
|
|
ad43ee7585 | ||
|
|
a1fe226d22 | ||
|
|
0bc7bbed5a | ||
|
|
0f178a502b | ||
|
|
db20fffeb5 | ||
|
|
9ca71dc7fc | ||
|
|
0117c87a55 | ||
|
|
120fd4a32b | ||
|
|
06e657dc4d | ||
|
|
f5857e2162 | ||
|
|
30280db810 | ||
|
|
d1221dae83 | ||
|
|
b99c18c5ac | ||
|
|
25544eb157 | ||
|
|
0f1ee174a0 | ||
|
|
786f91ab4d | ||
|
|
5baa2a3697 | ||
|
|
b9375c1d7b | ||
|
|
d393bc0ac5 | ||
|
|
ef9ed647c9 | ||
|
|
68d32bd0de | ||
|
|
ba76f2444d | ||
|
|
d9fde3ba79 | ||
|
|
f5b05bf32d | ||
|
|
f71eb0be5a | ||
|
|
3989d5e525 | ||
|
|
4ad67f7f77 | ||
|
|
39c49d4cdb | ||
|
|
6e669b2aa9 | ||
|
|
ac4ce2934c | ||
|
|
6a4fe83fbb | ||
|
|
04e31e8628 | ||
|
|
0fb2a6d32b | ||
|
|
fcffee1981 | ||
|
|
951a9d08ba | ||
|
|
3916c94f36 | ||
|
|
c7901c759a | ||
|
|
e852e40503 | ||
|
|
ac9bcbcb9f | ||
|
|
9e5aa16a7d | ||
|
|
ae963751cf | ||
|
|
13d4117cc1 | ||
|
|
3807f847fd | ||
|
|
67be97d857 | ||
|
|
af9f722b53 | ||
|
|
092f5e73d7 | ||
|
|
7fe7e4e321 | ||
|
|
d41040e6d3 | ||
|
|
a71832c6e5 | ||
|
|
eefd1d9d13 | ||
|
|
bbd12c540a | ||
|
|
0c436408e7 | ||
|
|
ebc77f4ee2 | ||
|
|
d6ee1807f3 | ||
|
|
0d7c3cb9da | ||
|
|
fd252247aa | ||
|
|
c12af2efe9 | ||
|
|
04b24ee86b | ||
|
|
43d64bc3d0 | ||
|
|
f7401bd60c | ||
|
|
6a3d0ae296 | ||
|
|
5c3da8b01a | ||
|
|
46c4b3e1d8 | ||
|
|
43d251fcda | ||
|
|
fed3725733 | ||
|
|
f5be204ac8 | ||
|
|
ba6322bb1f | ||
|
|
bf8687a473 | ||
|
|
09c6ad47d5 | ||
|
|
091a8ff6c3 | ||
|
|
cab5693ced | ||
|
|
be867a3b10 | ||
|
|
093fdcda45 | ||
|
|
eeaa027579 | ||
|
|
a4c20981cf | ||
|
|
57354e6b06 | ||
|
|
63965c9e64 | ||
|
|
c5f39f6f8a | ||
|
|
eb841601f6 | ||
|
|
3f5ce6dc43 | ||
|
|
2ed29e5a18 | ||
|
|
380172c5ac | ||
|
|
b73a33b05b | ||
|
|
e3baa2f58b | ||
|
|
8be1e2df0c | ||
|
|
ef7fbc985f | ||
|
|
381c6701f2 | ||
|
|
71da79ee6a | ||
|
|
5cff323871 | ||
|
|
39b7c66d34 | ||
|
|
57201f8606 | ||
|
|
eba9163ce8 | ||
|
|
4b166cf1d8 |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: '❌ bug'
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
|
||||||
- Device: [e.g. iPhone15Pro]
|
|
||||||
- OS: [e.g. iOS18]
|
|
||||||
- Version [e.g. 0.3.1]
|
|
||||||
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
title: '[Bug]: '
|
||||||
|
labels:
|
||||||
|
- ['❌ bug']
|
||||||
|
projects:
|
||||||
|
- ['fredrikburmester/5']
|
||||||
|
assignees:
|
||||||
|
- fredrikburmester
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: Also tell us, what did you expect to happen?
|
||||||
|
placeholder: A clear and concise description of what the bug is.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Reproduction steps
|
||||||
|
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||||
|
placeholder: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Which device and operating system are you using?
|
||||||
|
description: e.g. iPhone 15, iOS 18.1.1
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: What version of Streamyfin are you running?
|
||||||
|
options:
|
||||||
|
- 0.22.0
|
||||||
|
- 0.21.0
|
||||||
|
- older
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label:
|
||||||
|
If applicable, please add screenshots to help explain your problem.
|
||||||
|
You can drag and drop images here or paste them directly into the comment box.
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,6 +9,7 @@ npm-debug.*
|
|||||||
*.mobileprovision
|
*.mobileprovision
|
||||||
*.orig.*
|
*.orig.*
|
||||||
web-build/
|
web-build/
|
||||||
|
modules/vlc-player/android/build
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -26,8 +27,12 @@ package-lock.json
|
|||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|
||||||
|
modules/player/android
|
||||||
|
|
||||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||||
credentials.json
|
credentials.json
|
||||||
*.apk
|
*.apk
|
||||||
*.ipa
|
*.ipa
|
||||||
.continuerc.json
|
.continuerc.json
|
||||||
|
|
||||||
|
.vscode/
|
||||||
3
.idea/.gitignore
generated
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
329
.idea/caches/deviceStreaming.xml
generated
Normal file
329
.idea/caches/deviceStreaming.xml
generated
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceStreaming">
|
||||||
|
<option name="deviceSelectionList">
|
||||||
|
<list>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="27" />
|
||||||
|
<option name="brand" value="DOCOMO" />
|
||||||
|
<option name="codename" value="F01L" />
|
||||||
|
<option name="id" value="F01L" />
|
||||||
|
<option name="manufacturer" value="FUJITSU" />
|
||||||
|
<option name="name" value="F-01L" />
|
||||||
|
<option name="screenDensity" value="360" />
|
||||||
|
<option name="screenX" value="720" />
|
||||||
|
<option name="screenY" value="1280" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="28" />
|
||||||
|
<option name="brand" value="DOCOMO" />
|
||||||
|
<option name="codename" value="SH-01L" />
|
||||||
|
<option name="id" value="SH-01L" />
|
||||||
|
<option name="manufacturer" value="SHARP" />
|
||||||
|
<option name="name" value="AQUOS sense2 SH-01L" />
|
||||||
|
<option name="screenDensity" value="480" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2160" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="Lenovo" />
|
||||||
|
<option name="codename" value="TB370FU" />
|
||||||
|
<option name="id" value="TB370FU" />
|
||||||
|
<option name="manufacturer" value="Lenovo" />
|
||||||
|
<option name="name" value="Tab P12" />
|
||||||
|
<option name="screenDensity" value="340" />
|
||||||
|
<option name="screenX" value="1840" />
|
||||||
|
<option name="screenY" value="2944" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="31" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="a51" />
|
||||||
|
<option name="id" value="a51" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy A51" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="akita" />
|
||||||
|
<option name="id" value="akita" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 8a" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="b0q" />
|
||||||
|
<option name="id" value="b0q" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S22 Ultra" />
|
||||||
|
<option name="screenDensity" value="600" />
|
||||||
|
<option name="screenX" value="1440" />
|
||||||
|
<option name="screenY" value="3088" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="32" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="bluejay" />
|
||||||
|
<option name="id" value="bluejay" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 6a" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="caiman" />
|
||||||
|
<option name="id" value="caiman" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9 Pro" />
|
||||||
|
<option name="screenDensity" value="360" />
|
||||||
|
<option name="screenX" value="960" />
|
||||||
|
<option name="screenY" value="2142" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="comet" />
|
||||||
|
<option name="id" value="comet" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9 Pro Fold" />
|
||||||
|
<option name="screenDensity" value="390" />
|
||||||
|
<option name="screenX" value="2076" />
|
||||||
|
<option name="screenY" value="2152" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="29" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="crownqlteue" />
|
||||||
|
<option name="id" value="crownqlteue" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Note9" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="2220" />
|
||||||
|
<option name="screenY" value="1080" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="dm3q" />
|
||||||
|
<option name="id" value="dm3q" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S23 Ultra" />
|
||||||
|
<option name="screenDensity" value="600" />
|
||||||
|
<option name="screenX" value="1440" />
|
||||||
|
<option name="screenY" value="3088" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="e1q" />
|
||||||
|
<option name="id" value="e1q" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy S24" />
|
||||||
|
<option name="screenDensity" value="480" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="felix" />
|
||||||
|
<option name="id" value="felix" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Fold" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="2208" />
|
||||||
|
<option name="screenY" value="1840" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="felix" />
|
||||||
|
<option name="id" value="felix" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Fold" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="2208" />
|
||||||
|
<option name="screenY" value="1840" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="felix_camera" />
|
||||||
|
<option name="id" value="felix_camera" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="2208" />
|
||||||
|
<option name="screenY" value="1840" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="gts8uwifi" />
|
||||||
|
<option name="id" value="gts8uwifi" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Tab S8 Ultra" />
|
||||||
|
<option name="screenDensity" value="320" />
|
||||||
|
<option name="screenX" value="1848" />
|
||||||
|
<option name="screenY" value="2960" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="husky" />
|
||||||
|
<option name="id" value="husky" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 8 Pro" />
|
||||||
|
<option name="screenDensity" value="390" />
|
||||||
|
<option name="screenX" value="1008" />
|
||||||
|
<option name="screenY" value="2244" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="30" />
|
||||||
|
<option name="brand" value="motorola" />
|
||||||
|
<option name="codename" value="java" />
|
||||||
|
<option name="id" value="java" />
|
||||||
|
<option name="manufacturer" value="Motorola" />
|
||||||
|
<option name="name" value="G20" />
|
||||||
|
<option name="screenDensity" value="280" />
|
||||||
|
<option name="screenX" value="720" />
|
||||||
|
<option name="screenY" value="1600" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="komodo" />
|
||||||
|
<option name="id" value="komodo" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9 Pro XL" />
|
||||||
|
<option name="screenDensity" value="360" />
|
||||||
|
<option name="screenX" value="1008" />
|
||||||
|
<option name="screenY" value="2244" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="lynx" />
|
||||||
|
<option name="id" value="lynx" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 7a" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="31" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="oriole" />
|
||||||
|
<option name="id" value="oriole" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 6" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="panther" />
|
||||||
|
<option name="id" value="panther" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 7" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="q5q" />
|
||||||
|
<option name="id" value="q5q" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Z Fold5" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1812" />
|
||||||
|
<option name="screenY" value="2176" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="samsung" />
|
||||||
|
<option name="codename" value="q6q" />
|
||||||
|
<option name="id" value="q6q" />
|
||||||
|
<option name="manufacturer" value="Samsung" />
|
||||||
|
<option name="name" value="Galaxy Z Fold6" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1856" />
|
||||||
|
<option name="screenY" value="2160" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="30" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="r11" />
|
||||||
|
<option name="id" value="r11" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Watch" />
|
||||||
|
<option name="screenDensity" value="320" />
|
||||||
|
<option name="screenX" value="384" />
|
||||||
|
<option name="screenY" value="384" />
|
||||||
|
<option name="type" value="WEAR_OS" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="30" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="redfin" />
|
||||||
|
<option name="id" value="redfin" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 5" />
|
||||||
|
<option name="screenDensity" value="440" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2340" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="shiba" />
|
||||||
|
<option name="id" value="shiba" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 8" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2400" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="33" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="tangorpro" />
|
||||||
|
<option name="id" value="tangorpro" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Tablet" />
|
||||||
|
<option name="screenDensity" value="320" />
|
||||||
|
<option name="screenX" value="1600" />
|
||||||
|
<option name="screenY" value="2560" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="34" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="tokay" />
|
||||||
|
<option name="id" value="tokay" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel 9" />
|
||||||
|
<option name="screenDensity" value="420" />
|
||||||
|
<option name="screenX" value="1080" />
|
||||||
|
<option name="screenY" value="2424" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/streamyfin.iml" filepath="$PROJECT_DIR$/.idea/streamyfin.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
.idea/streamyfin.iml
generated
Normal file
9
.idea/streamyfin.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -8,5 +8,8 @@
|
|||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[swift]": {
|
||||||
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
app.json
31
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.17.0",
|
"version": "0.22.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -23,7 +23,10 @@
|
|||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
}
|
},
|
||||||
|
"UISupportsTrueScreenSizeOnMac": true,
|
||||||
|
"UIFileSharingEnabled": true,
|
||||||
|
"LSSupportsOpeningDocumentsInPlace": true
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
@@ -33,14 +36,15 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 43,
|
"versionCode": 47,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
|
"android.permission.WRITE_SETTINGS"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
@@ -66,18 +70,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"./plugins/withAndroidMainActivityAttributes",
|
|
||||||
{
|
|
||||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
["./plugins/withExpandedController.js"],
|
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6"
|
"deploymentTarget": "15.6",
|
||||||
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"android": {
|
"android": {
|
||||||
@@ -106,7 +104,14 @@
|
|||||||
{
|
{
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"expo-asset",
|
||||||
|
[
|
||||||
|
"react-native-edge-to-edge",
|
||||||
|
{ "android": { "parentTheme": "Material3" } }
|
||||||
|
],
|
||||||
|
["react-native-bottom-tabs"],
|
||||||
|
["./plugins/withChangeNativeAndroidTextToWhite.js"]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
20
app/(auth)/(tabs)/(custom-links)/_layout.tsx
Normal file
20
app/(auth)/(tabs)/(custom-links)/_layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {Stack} from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function CustomMenuLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: "Custom Links",
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
app/(auth)/(tabs)/(custom-links)/index.tsx
Normal file
73
app/(auth)/(tabs)/(custom-links)/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {FlatList, TouchableOpacity, View} from "react-native";
|
||||||
|
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
||||||
|
import React, {useCallback, useEffect, useState} from "react";
|
||||||
|
import {useAtom} from "jotai/index";
|
||||||
|
import {apiAtom} from "@/providers/JellyfinProvider";
|
||||||
|
import {ListItem} from "@/components/ListItem";
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
|
||||||
|
export interface MenuLink {
|
||||||
|
name: string,
|
||||||
|
url: string,
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function menuLinks() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
|
||||||
|
|
||||||
|
const getMenuLinks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
|
||||||
|
const config = response?.data;
|
||||||
|
|
||||||
|
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||||
|
console.error("Menu links not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuLinks(config?.menuLinks as MenuLink[])
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve config:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => { getMenuLinks() }, []);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
data={menuLinks}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
|
||||||
|
<ListItem
|
||||||
|
title={item.name}
|
||||||
|
iconAfter={<Ionicons name="link" size={24} color="white"/>}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
|
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -27,7 +24,6 @@ export default function IndexLayout() {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
}}
|
}}
|
||||||
className="p-2 "
|
|
||||||
>
|
>
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -36,11 +32,17 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="downloads"
|
name="downloads/index"
|
||||||
options={{
|
options={{
|
||||||
title: "Downloads",
|
title: "Downloads",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="downloads/[seriesId]"
|
||||||
|
options={{
|
||||||
|
title: "TV-Series",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
const downloads: React.FC = () => {
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
const { removeProcess, downloadedFiles } = useDownload();
|
|
||||||
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const movies = useMemo(
|
|
||||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
|
||||||
[downloadedFiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
|
||||||
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
|
|
||||||
const series: { [key: string]: BaseItemDto[] } = {};
|
|
||||||
episodes?.forEach((e) => {
|
|
||||||
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
|
|
||||||
series[e.SeriesName!].push(e);
|
|
||||||
});
|
|
||||||
return Object.values(series);
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="py-4">
|
|
||||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
|
||||||
{settings?.downloadMethod === "remux" && (
|
|
||||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
|
||||||
<Text className="text-lg font-bold">Queue</Text>
|
|
||||||
<Text className="text-xs opacity-70 text-red-600">
|
|
||||||
Queue and downloads will be lost on app restart
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-col space-y-2 mt-2">
|
|
||||||
{queue.map((q) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
|
||||||
}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeProcess(q.id);
|
|
||||||
setQueue((prev) => {
|
|
||||||
if (!prev) return [];
|
|
||||||
return [...prev.filter((i) => i.id !== q.id)];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className="opacity-50">No items in queue</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ActiveDownloads />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
|
||||||
<Text className="text-lg font-bold">Movies</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{movies?.map((item: BaseItemDto) => (
|
|
||||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
|
||||||
<MovieCard item={item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
|
||||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
|
||||||
))}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className="flex px-4">
|
|
||||||
<Text className="opacity-50">No downloaded items</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default downloads;
|
|
||||||
132
app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
Normal file
132
app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
||||||
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const { seriesId, episodeSeasonIndex } = local as {
|
||||||
|
seriesId: string;
|
||||||
|
episodeSeasonIndex: number | string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const { downloadedFiles, deleteItems } = useDownload();
|
||||||
|
|
||||||
|
const series = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
downloadedFiles
|
||||||
|
?.filter((f) => f.item.SeriesId == seriesId)
|
||||||
|
?.sort(
|
||||||
|
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const seasonIndex =
|
||||||
|
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||||
|
episodeSeasonIndex ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||||
|
const seasons: Record<string, BaseItemDto[]> = {};
|
||||||
|
|
||||||
|
series?.forEach((episode) => {
|
||||||
|
if (!seasons[episode.item.ParentIndexNumber!]) {
|
||||||
|
seasons[episode.item.ParentIndexNumber!] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}, [series, seasonIndex]);
|
||||||
|
|
||||||
|
const initialSeasonIndex = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||||
|
series?.[0]?.item?.ParentIndexNumber,
|
||||||
|
[groupBySeason]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (series.length > 0) {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: series[0].item.SeriesName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
storage.delete(seriesId);
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
const deleteSeries = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
"Delete season",
|
||||||
|
"Are you sure you want to delete the entire season?",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
onPress: () => deleteItems(groupBySeason),
|
||||||
|
style: "destructive",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, [groupBySeason]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1">
|
||||||
|
{series.length > 0 && (
|
||||||
|
<View className="flex flex-row items-center justify-start my-2 px-4">
|
||||||
|
<SeasonDropdown
|
||||||
|
item={series[0].item}
|
||||||
|
seasons={series.map((s) => s.item)}
|
||||||
|
state={seasonIndexState}
|
||||||
|
initialSeasonIndex={initialSeasonIndex!}
|
||||||
|
onSelect={(season) => {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
||||||
|
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
||||||
|
<TouchableOpacity onPress={deleteSeries}>
|
||||||
|
<Ionicons name="trash" size={20} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ScrollView key={seasonIndex} className="px-4">
|
||||||
|
{groupBySeason.map((episode, index) => (
|
||||||
|
<EpisodeCard key={index} item={episode} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
app/(auth)/(tabs)/(home)/downloads/index.tsx
Normal file
231
app/(auth)/(tabs)/(home)/downloads/index.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
|
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {useNavigation, useRouter} from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {useEffect, useMemo, useRef} from "react";
|
||||||
|
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||||
|
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
||||||
|
import {toast} from "sonner-native";
|
||||||
|
import {writeToLog} from "@/utils/log";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
|
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
const movies = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||||
|
} catch {
|
||||||
|
migration_20241124();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const groupedBySeries = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const episodes = downloadedFiles?.filter(
|
||||||
|
(f) => f.item.Type === "Episode"
|
||||||
|
);
|
||||||
|
const series: { [key: string]: DownloadedItem[] } = {};
|
||||||
|
episodes?.forEach((e) => {
|
||||||
|
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
||||||
|
series[e.item.SeriesName!].push(e);
|
||||||
|
});
|
||||||
|
return Object.values(series);
|
||||||
|
} catch {
|
||||||
|
migration_20241124();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
|
>
|
||||||
|
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const deleteMovies = () => deleteFileByType("Movie")
|
||||||
|
.then(() => toast.success("Deleted all movies successfully!"))
|
||||||
|
.catch((reason) => {
|
||||||
|
writeToLog("ERROR", reason);
|
||||||
|
toast.error("Failed to delete all movies");
|
||||||
|
});
|
||||||
|
const deleteShows = () => deleteFileByType("Episode")
|
||||||
|
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||||
|
.catch((reason) => {
|
||||||
|
writeToLog("ERROR", reason);
|
||||||
|
toast.error("Failed to delete all TV-Series");
|
||||||
|
});
|
||||||
|
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="py-4">
|
||||||
|
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||||
|
{settings?.downloadMethod === "remux" && (
|
||||||
|
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
|
<Text className="text-lg font-bold">Queue</Text>
|
||||||
|
<Text className="text-xs opacity-70 text-red-600">
|
||||||
|
Queue and downloads will be lost on app restart
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-col space-y-2 mt-2">
|
||||||
|
{queue.map((q, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||||
|
}
|
||||||
|
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text className="font-semibold">{q.item.Name}</Text>
|
||||||
|
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
removeProcess(q.id);
|
||||||
|
setQueue((prev) => {
|
||||||
|
if (!prev) return [];
|
||||||
|
return [...prev.filter((i) => i.id !== q.id)];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="red"/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{queue.length === 0 && (
|
||||||
|
<Text className="opacity-50">No items in queue</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ActiveDownloads/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{movies.length > 0 && (
|
||||||
|
<View className="mb-4">
|
||||||
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
|
<Text className="text-lg font-bold">Movies</Text>
|
||||||
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
|
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className="px-4 flex flex-row">
|
||||||
|
{movies?.map((item) => (
|
||||||
|
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||||
|
<MovieCard item={item.item}/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{groupedBySeries.length > 0 && (
|
||||||
|
<View className="mb-4">
|
||||||
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
|
<Text className="text-lg font-bold">TV-Series</Text>
|
||||||
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
|
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className="px-4 flex flex-row">
|
||||||
|
{groupedBySeries?.map((items) => (
|
||||||
|
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
||||||
|
<SeriesCard
|
||||||
|
items={items.map((i) => i.item)}
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className="flex px-4">
|
||||||
|
<Text className="opacity-50">No downloaded items</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className="p-4 space-y-4 mb-4">
|
||||||
|
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
|
||||||
|
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
|
||||||
|
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migration_20241124() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { deleteAllFiles } = useDownload();
|
||||||
|
Alert.alert(
|
||||||
|
"New app version requires re-download",
|
||||||
|
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Back",
|
||||||
|
onPress: () => router.back(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => await deleteAllFiles(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
|
|||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -25,11 +25,10 @@ import {
|
|||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -54,7 +53,6 @@ type MediaListSection = {
|
|||||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -66,9 +64,11 @@ export default function index() {
|
|||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
const { downloadedFiles } = useDownload();
|
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
@@ -107,6 +107,9 @@ export default function index() {
|
|||||||
setIsConnected(state.isConnected);
|
setIsConnected(state.isConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cleanCacheDirectory()
|
||||||
|
.then(r => console.log("Cache directory cleaned"))
|
||||||
|
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
@@ -165,28 +168,13 @@ export default function index() {
|
|||||||
);
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await queryClient.invalidateQueries({
|
await invalidateCache();
|
||||||
queryKey: ["home"],
|
|
||||||
refetchType: "all",
|
|
||||||
type: "all",
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["home"],
|
|
||||||
refetchType: "all",
|
|
||||||
type: "all",
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["item"],
|
|
||||||
refetchType: "all",
|
|
||||||
type: "all",
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [queryClient]);
|
}, []);
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
const createCollectionConfig = useCallback(
|
||||||
(
|
(
|
||||||
@@ -203,7 +191,7 @@ export default function index() {
|
|||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 50,
|
limit: 20,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
@@ -242,7 +230,7 @@ export default function index() {
|
|||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
{
|
{
|
||||||
title: "Continue Watching",
|
title: "Continue Watching",
|
||||||
queryKey: ["home", "resumeItems", user.Id],
|
queryKey: ["home", "resumeItems"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
@@ -256,7 +244,7 @@ export default function index() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Next Up",
|
title: "Next Up",
|
||||||
queryKey: ["home", "nextUp-all", user?.Id],
|
queryKey: ["home", "nextUp-all"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
@@ -362,8 +350,6 @@ export default function index() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (e1 || e2)
|
if (e1 || e2)
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
@@ -388,15 +374,11 @@ export default function index() {
|
|||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
key={"home"}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
marginBottom: TAB_HEIGHT,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-4">
|
<View className="flex flex-col space-y-4">
|
||||||
<LargeMovieCarousel />
|
<LargeMovieCarousel />
|
||||||
|
|||||||
@@ -2,32 +2,41 @@ import { Button } from "@/components/Button";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import {clearLogs, useLog} from "@/utils/log";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import {Alert, ScrollView, View} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import * as Progress from 'react-native-progress';
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
|
const { logs } = useLog();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const { data: logs } = useQuery({
|
|
||||||
queryKey: ["logs"],
|
|
||||||
queryFn: async () => readFromLog(),
|
|
||||||
refetchInterval: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const {data: size , isLoading: appSizeLoading } = useQuery({
|
||||||
|
queryKey: ["appSize", appSizeUsage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const app = await appSizeUsage
|
||||||
|
|
||||||
|
const remaining = await FileSystem.getFreeDiskStorageAsync()
|
||||||
|
const total = await FileSystem.getTotalDiskCapacityAsync()
|
||||||
|
|
||||||
|
return {app, remaining, total, used: (total - remaining) / total}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
Alert.prompt(
|
Alert.prompt(
|
||||||
"Quick connect",
|
"Quick connect",
|
||||||
@@ -57,6 +66,27 @@ export default function settings() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDeleteClicked = async () => {
|
||||||
|
try {
|
||||||
|
await deleteAllFiles();
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Error
|
||||||
|
);
|
||||||
|
toast.error("Error deleting files");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClearLogsClicked = async () => {
|
||||||
|
clearLogs();
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -81,6 +111,9 @@ export default function settings() {
|
|||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
<ListItem title="Token" subTitle={api?.accessToken} />
|
||||||
</View>
|
</View>
|
||||||
|
<Button className="my-2.5" color="black" onPress={logout}>
|
||||||
|
Log out
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
@@ -92,42 +125,36 @@ export default function settings() {
|
|||||||
|
|
||||||
<SettingToggles />
|
<SettingToggles />
|
||||||
|
|
||||||
<View>
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
<Text className="font-bold text-lg mb-2">Storage</Text>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="mb-4 space-y-2">
|
||||||
<Button color="black" onPress={logout}>
|
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
|
||||||
Log out
|
<Progress.Bar
|
||||||
</Button>
|
className="bg-gray-100/10"
|
||||||
<Button
|
indeterminate={appSizeLoading}
|
||||||
color="red"
|
color="#9333ea"
|
||||||
onPress={async () => {
|
width={null}
|
||||||
try {
|
height={10}
|
||||||
await deleteAllFiles();
|
borderRadius={6}
|
||||||
Haptics.notificationAsync(
|
borderWidth={0}
|
||||||
Haptics.NotificationFeedbackType.Success
|
progress={size?.used}
|
||||||
);
|
/>
|
||||||
} catch (e) {
|
{size && (
|
||||||
Haptics.notificationAsync(
|
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
|
||||||
Haptics.NotificationFeedbackType.Error
|
)}
|
||||||
);
|
|
||||||
toast.error("Error deleting files");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await clearLogs();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all logs
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={onDeleteClicked}
|
||||||
|
>
|
||||||
|
Delete all downloaded files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={onClearLogsClicked}
|
||||||
|
>
|
||||||
|
Delete all logs
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -22,16 +22,18 @@ const Page: React.FC = () => {
|
|||||||
const { data: item, isError } = useQuery({
|
const { data: item, isError } = useQuery({
|
||||||
queryKey: ["item", id],
|
queryKey: ["item", id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getUserItemData({
|
if (!api || !user || !id) return;
|
||||||
api,
|
const res = await getUserLibraryApi(api).getItem({
|
||||||
userId: user?.Id,
|
|
||||||
itemId: id,
|
itemId: id,
|
||||||
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res;
|
return res.data;
|
||||||
},
|
},
|
||||||
enabled: !!id && !!api,
|
staleTime: 0,
|
||||||
staleTime: 60 * 1000 * 5, // 5 minutes
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
@@ -42,20 +44,25 @@ const Page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fadeOut = (callback: any) => {
|
const fadeOut = (callback: any) => {
|
||||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
setTimeout(() => {
|
||||||
if (finished) {
|
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||||
runOnJS(callback)();
|
if (finished) {
|
||||||
}
|
runOnJS(callback)();
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fadeIn = (callback: any) => {
|
const fadeIn = (callback: any) => {
|
||||||
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
|
setTimeout(() => {
|
||||||
if (finished) {
|
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||||
runOnJS(callback)();
|
if (finished) {
|
||||||
}
|
runOnJS(callback)();
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
fadeOut(() => {});
|
fadeOut(() => {});
|
||||||
@@ -78,14 +85,24 @@ const Page: React.FC = () => {
|
|||||||
style={[animatedStyle]}
|
style={[animatedStyle]}
|
||||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||||
>
|
>
|
||||||
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
|
<View
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
|
style={{
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
|
height: item?.Type === "Episode" ? 300 : 450,
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
|
}}
|
||||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
className="bg-transparent rounded-lg mb-4 w-full"
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
></View>
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
||||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
|
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
||||||
|
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
||||||
|
<View className="flex flex-row space-x-1 mb-8">
|
||||||
|
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||||
|
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||||
|
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||||
|
</View>
|
||||||
|
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
||||||
|
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||||
|
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||||
|
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && <ItemContent item={item} />}
|
{item && <ItemContent item={item} />}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import { Button, Dimensions, ScrollView, View } from "react-native";
|
import {
|
||||||
|
Button,
|
||||||
|
Dimensions,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const HOUR_HEIGHT = 30;
|
const HOUR_HEIGHT = 30;
|
||||||
@@ -78,8 +86,6 @@ export default function page() {
|
|||||||
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
|
||||||
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
|
|
||||||
|
|
||||||
const [scrollX, setScrollX] = useState(0);
|
const [scrollX, setScrollX] = useState(0);
|
||||||
|
|
||||||
const handleNextPage = useCallback(() => {
|
const handleNextPage = useCallback(() => {
|
||||||
@@ -100,24 +106,15 @@ export default function page() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
marginBottom: TAB_HEIGHT,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View className="flex flex-row bg-neutral-800 w-full items-end">
|
<PageButtons
|
||||||
<Button
|
currentPage={currentPage}
|
||||||
title="Previous"
|
onPrevPage={handlePrevPage}
|
||||||
onPress={handlePrevPage}
|
onNextPage={handleNextPage}
|
||||||
disabled={currentPage === 1}
|
isNextDisabled={
|
||||||
/>
|
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||||
<Button
|
}
|
||||||
title="Next"
|
/>
|
||||||
onPress={handleNextPage}
|
|
||||||
disabled={
|
|
||||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row">
|
<View className="flex flex-row">
|
||||||
<View className="flex flex-col w-[64px]">
|
<View className="flex flex-col w-[64px]">
|
||||||
@@ -166,3 +163,57 @@ export default function page() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PageButtonsProps {
|
||||||
|
currentPage: number;
|
||||||
|
onPrevPage: () => void;
|
||||||
|
onNextPage: () => void;
|
||||||
|
isNextDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageButtons: React.FC<PageButtonsProps> = ({
|
||||||
|
currentPage,
|
||||||
|
onPrevPage,
|
||||||
|
onNextPage,
|
||||||
|
isNextDisabled,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPrevPage}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="flex flex-row items-center"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={24}
|
||||||
|
color={currentPage === 1 ? "gray" : "white"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className={`ml-1 ${
|
||||||
|
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-white">Page {currentPage}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onNextPage}
|
||||||
|
disabled={isNextDisabled}
|
||||||
|
className="flex flex-row items-center"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={24}
|
||||||
|
color={isNextDisabled ? "gray" : "white"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { ScrollView, View } from "react-native";
|
||||||
ScrollView,
|
|
||||||
View
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -27,9 +24,6 @@ export default function page() {
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
marginBottom: TAB_HEIGHT,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
@@ -6,15 +7,17 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
const { id: seriesId, seasonIndex } = params as {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,6 +59,46 @@ const page: React.FC = () => {
|
|||||||
[item]
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
|
queryKey: ["AllEpisodes", item?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getTvShowsApi(api!).getEpisodes({
|
||||||
|
seriesId: item?.Id!,
|
||||||
|
userId: user?.Id!,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||||
|
});
|
||||||
|
return res?.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () =>
|
||||||
|
!isLoading &&
|
||||||
|
allEpisodes &&
|
||||||
|
allEpisodes.length > 0 && (
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<DownloadItems
|
||||||
|
title="Download Series"
|
||||||
|
items={allEpisodes || []}
|
||||||
|
MissingDownloadIconComponent={() => (
|
||||||
|
<Ionicons name="download" size={22} color="white" />
|
||||||
|
)}
|
||||||
|
DownloadedIconComponent={() => (
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-done-outline"
|
||||||
|
size={24}
|
||||||
|
color="#9333ea"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [allEpisodes, isLoading]);
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
if (!item || !backdropUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
useFocusEffect,
|
|
||||||
useLocalSearchParams,
|
|
||||||
useNavigation,
|
|
||||||
} from "expo-router";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -16,6 +12,7 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
@@ -32,10 +29,10 @@ import {
|
|||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -44,8 +41,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -60,12 +56,13 @@ const Page = () => {
|
|||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
const [orientation] = useAtom(orientationAtom);
|
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom
|
sortOrderPreferenceAtom
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sop) {
|
if (sop) {
|
||||||
@@ -106,11 +103,12 @@ const Page = () => {
|
|||||||
[libraryId, sortOrderPreference]
|
[libraryId, sortOrderPreference]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getNumberOfColumns = useCallback(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 600) return 5;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 960) return 6;
|
if (screenWidth < 800) return 5;
|
||||||
if (screenWidth < 1280) return 7;
|
if (screenWidth < 1000) return 6;
|
||||||
|
if (screenWidth < 1500) return 7;
|
||||||
return 6;
|
return 6;
|
||||||
}, [screenWidth, orientation]);
|
}, [screenWidth, orientation]);
|
||||||
|
|
||||||
@@ -143,6 +141,18 @@ const Page = () => {
|
|||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !library) return null;
|
if (!api || !library) return null;
|
||||||
|
|
||||||
|
console.log("[libraryId] ~", library);
|
||||||
|
|
||||||
|
let itemType: BaseItemKind | undefined;
|
||||||
|
|
||||||
|
// This fix makes sure to only return 1 type of items, if defined.
|
||||||
|
// This is because the underlying directory some times contains other types, and we don't want to show them.
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
itemType = "Movie";
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
itemType = "Series";
|
||||||
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -157,6 +167,7 @@ const Page = () => {
|
|||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -219,7 +230,7 @@ const Page = () => {
|
|||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<MemoizedTouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -230,10 +241,10 @@ const Page = () => {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignSelf:
|
alignSelf:
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
? index % 3 === 0
|
? index % nrOfCols === 0
|
||||||
? "flex-end"
|
? "flex-end"
|
||||||
: (index + 1) % 3 === 0
|
: (index + 1) % nrOfCols === 0
|
||||||
? "flex-start"
|
? "flex-start"
|
||||||
: "center"
|
: "center"
|
||||||
: "center",
|
: "center",
|
||||||
@@ -244,7 +255,7 @@ const Page = () => {
|
|||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</MemoizedTouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation]
|
||||||
);
|
);
|
||||||
@@ -429,6 +440,7 @@ const Page = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
|
key={orientation}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
||||||
@@ -437,10 +449,10 @@ const Page = () => {
|
|||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={orientation}
|
extraData={[orientation, nrOfCols]}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={244}
|
estimatedItemSize={244}
|
||||||
numColumns={getNumberOfColumns()}
|
numColumns={nrOfCols}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -8,7 +7,6 @@ import { Loader } from "@/components/Loader";
|
|||||||
import AlbumCover from "@/components/posters/AlbumCover";
|
import AlbumCover from "@/components/posters/AlbumCover";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
@@ -72,37 +70,43 @@ export default function search() {
|
|||||||
types: BaseItemKind[];
|
types: BaseItemKind[];
|
||||||
query: string;
|
query: string;
|
||||||
}): Promise<BaseItemDto[]> => {
|
}): Promise<BaseItemDto[]> => {
|
||||||
if (!api) return [];
|
if (!api || !query) return [];
|
||||||
|
|
||||||
if (searchEngine === "Jellyfin") {
|
try {
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
if (searchEngine === "Jellyfin") {
|
||||||
searchTerm: query,
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
limit: 10,
|
searchTerm: query,
|
||||||
includeItemTypes: types,
|
limit: 10,
|
||||||
});
|
includeItemTypes: types,
|
||||||
|
});
|
||||||
|
|
||||||
return searchApi.data.SearchHints as BaseItemDto[];
|
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||||
} else {
|
} else {
|
||||||
const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
|
if (!settings?.marlinServerUrl) return [];
|
||||||
query
|
const url = `${
|
||||||
)}&includeItemTypes=${types
|
settings.marlinServerUrl
|
||||||
.map((type) => encodeURIComponent(type))
|
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
.join("&includeItemTypes=")}`;
|
.map((type) => encodeURIComponent(type))
|
||||||
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
const response1 = await axios.get(url);
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
if (!ids || !ids.length) return [];
|
if (!ids || !ids.length) return [];
|
||||||
|
|
||||||
const response2 = await getItemsApi(api).getItems({
|
const response2 = await getItemsApi(api).getItems({
|
||||||
ids,
|
ids,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return response2.data.Items as BaseItemDto[];
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during search:", error);
|
||||||
|
return []; // Ensure an empty array is returned in case of an error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, settings]
|
[api, searchEngine, settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -226,10 +230,6 @@ export default function search() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
marginBottom: TAB_HEIGHT,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-2">
|
<View className="flex flex-col pt-2">
|
||||||
|
|||||||
@@ -1,87 +1,91 @@
|
|||||||
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
import React from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
import { withLayoutContext } from "expo-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createNativeBottomTabNavigator,
|
||||||
|
NativeBottomTabNavigationEventMap,
|
||||||
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
|
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { BlurView } from "expo-blur";
|
import type {
|
||||||
import * as NavigationBar from "expo-navigation-bar";
|
ParamListBase,
|
||||||
import { Tabs } from "expo-router";
|
TabNavigationState,
|
||||||
import React, { useEffect } from "react";
|
} from "@react-navigation/native";
|
||||||
import { Platform, StyleSheet } from "react-native";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export const NativeTabs = withLayoutContext<
|
||||||
|
BottomTabNavigationOptions,
|
||||||
|
typeof Navigator,
|
||||||
|
TabNavigationState<ParamListBase>,
|
||||||
|
NativeBottomTabNavigationEventMap
|
||||||
|
>(Navigator);
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
useEffect(() => {
|
const [settings] = useSettings();
|
||||||
if (Platform.OS === "android") {
|
|
||||||
NavigationBar.setBackgroundColorAsync("#121212");
|
|
||||||
NavigationBar.setBorderColorAsync("#121212");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<>
|
||||||
initialRouteName="home"
|
<SystemBars hidden={false} style="light" />
|
||||||
screenOptions={{
|
<NativeTabs
|
||||||
tabBarActiveTintColor: Colors.tabIconSelected,
|
sidebarAdaptable
|
||||||
headerShown: false,
|
ignoresTopSafeArea
|
||||||
tabBarStyle: {
|
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
||||||
position: "absolute",
|
tabBarActiveTintColor={Colors.primary}
|
||||||
borderTopLeftRadius: 0,
|
scrollEdgeAppearance="default"
|
||||||
borderTopRightRadius: 0,
|
>
|
||||||
borderTopWidth: 0,
|
<NativeTabs.Screen redirect name="index" />
|
||||||
paddingTop: 8,
|
<NativeTabs.Screen
|
||||||
paddingBottom: Platform.OS === "android" ? 8 : 26,
|
name="(home)"
|
||||||
height: Platform.OS === "android" ? 58 : 74,
|
options={{
|
||||||
},
|
title: "Home",
|
||||||
tabBarBackground: () =>
|
tabBarIcon:
|
||||||
Platform.OS === "ios" ? (
|
Platform.OS == "android"
|
||||||
<BlurView
|
? ({ color, focused, size }) =>
|
||||||
experimentalBlurMethod="dimezisBlurView"
|
require("@/assets/icons/house.fill.png")
|
||||||
intensity={95}
|
: () => ({ sfSymbol: "house" }),
|
||||||
style={{
|
}}
|
||||||
...StyleSheet.absoluteFillObject,
|
/>
|
||||||
overflow: "hidden",
|
<NativeTabs.Screen
|
||||||
borderTopLeftRadius: 0,
|
name="(search)"
|
||||||
borderTopRightRadius: 0,
|
options={{
|
||||||
backgroundColor: "black",
|
title: "Search",
|
||||||
}}
|
tabBarIcon:
|
||||||
/>
|
Platform.OS == "android"
|
||||||
) : undefined,
|
? ({ color, focused, size }) =>
|
||||||
}}
|
require("@/assets/icons/magnifyingglass.png")
|
||||||
>
|
: () => ({ sfSymbol: "magnifyingglass" }),
|
||||||
<Tabs.Screen redirect name="index" />
|
}}
|
||||||
<Tabs.Screen
|
/>
|
||||||
name="(home)"
|
<NativeTabs.Screen
|
||||||
options={{
|
name="(libraries)"
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "Home",
|
title: "Library",
|
||||||
tabBarIcon: ({ color, focused }) => (
|
tabBarIcon:
|
||||||
<TabBarIcon
|
Platform.OS == "android"
|
||||||
name={focused ? "home" : "home-outline"}
|
? ({ color, focused, size }) =>
|
||||||
color={color}
|
require("@/assets/icons/server.rack.png")
|
||||||
/>
|
: () => ({ sfSymbol: "rectangle.stack" }),
|
||||||
),
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<NativeTabs.Screen
|
||||||
<Tabs.Screen
|
name="(custom-links)"
|
||||||
name="(search)"
|
options={{
|
||||||
options={{
|
title: "Custom Links",
|
||||||
headerShown: false,
|
// @ts-expect-error
|
||||||
title: "Search",
|
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||||
tabBarIcon: ({ color, focused }) => (
|
tabBarIcon:
|
||||||
<TabBarIcon name={focused ? "search" : "search"} color={color} />
|
Platform.OS == "android"
|
||||||
),
|
? () => require("@/assets/icons/list.png")
|
||||||
}}
|
: () => ({ sfSymbol: "list.dash" }),
|
||||||
/>
|
}}
|
||||||
<Tabs.Screen
|
/>
|
||||||
name="(libraries)"
|
</NativeTabs>
|
||||||
options={{
|
</>
|
||||||
headerShown: false,
|
|
||||||
title: "Library",
|
|
||||||
tabBarIcon: ({ color, focused }) => (
|
|
||||||
<TabBarIcon
|
|
||||||
name={focused ? "apps" : "apps-outline"}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import AlbumCover from "@/components/posters/AlbumCover";
|
|
||||||
import { Controls } from "@/components/video-player/Controls";
|
|
||||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
PlaybackType,
|
|
||||||
usePlaySettings,
|
|
||||||
} from "@/providers/PlaySettingsProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useFocusEffect } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { debounce } from "lodash";
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const poster = usePoster(playSettings, api);
|
|
||||||
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
|
|
||||||
const screenDimensions = Dimensions.get("screen");
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
|
||||||
async (ticks: number) => {
|
|
||||||
console.log("togglePlay");
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: playSettings.item?.Id!,
|
|
||||||
audioStreamIndex: playSettings.audioIndex
|
|
||||||
? playSettings.audioIndex
|
|
||||||
: undefined,
|
|
||||||
subtitleStreamIndex: playSettings.subtitleIndex
|
|
||||||
? playSettings.subtitleIndex
|
|
||||||
: undefined,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: playSettings.item?.Id!,
|
|
||||||
audioStreamIndex: playSettings.audioIndex
|
|
||||||
? playSettings.audioIndex
|
|
||||||
: undefined,
|
|
||||||
subtitleStreamIndex: playSettings.subtitleIndex
|
|
||||||
? playSettings.subtitleIndex
|
|
||||||
: undefined,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
console.log("stop");
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.pause();
|
|
||||||
reportPlaybackStopped();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
await getPlaystateApi(api).onPlaybackStopped({
|
|
||||||
itemId: playSettings?.item?.Id!,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
await getPlaystateApi(api).onPlaybackStart({
|
|
||||||
itemId: playSettings?.item?.Id!,
|
|
||||||
audioStreamIndex: playSettings.audioIndex
|
|
||||||
? playSettings.audioIndex
|
|
||||||
: undefined,
|
|
||||||
subtitleStreamIndex: playSettings.subtitleIndex
|
|
||||||
? playSettings.subtitleIndex
|
|
||||||
: undefined,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = data.currentTime * 10000000;
|
|
||||||
|
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!playSettings?.item?.Id || data.currentTime === 0) return;
|
|
||||||
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: playSettings.item.Id,
|
|
||||||
audioStreamIndex: playSettings.audioIndex
|
|
||||||
? playSettings.audioIndex
|
|
||||||
: undefined,
|
|
||||||
subtitleStreamIndex: playSettings.subtitleIndex
|
|
||||||
? playSettings.subtitleIndex
|
|
||||||
: undefined,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [play, stop])
|
|
||||||
);
|
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
useAndroidNavigationBar();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: screenDimensions.width,
|
|
||||||
height: screenDimensions.height,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<StatusBar hidden />
|
|
||||||
|
|
||||||
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
|
||||||
<Image
|
|
||||||
source={poster}
|
|
||||||
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full opacity-0"
|
|
||||||
>
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={() => {}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Controls
|
|
||||||
item={playSettings.item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
enableTrickplay={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
playSettings: PlaybackType | null,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!playSettings?.item || !api) return undefined;
|
|
||||||
return playSettings.item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: playSettings.item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [playSettings?.item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
playSettings: PlaybackType | null,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
playUrl?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!playSettings || !api || !playUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: playUrl,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
|
||||||
title: playSettings.item?.Name || "Unknown",
|
|
||||||
description: playSettings.item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: playSettings.item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [playSettings, api, poster]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { Controls } from "@/components/video-player/Controls";
|
|
||||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
PlaybackType,
|
|
||||||
usePlaySettings,
|
|
||||||
} from "@/providers/PlaySettingsProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import * as NavigationBar from "expo-navigation-bar";
|
|
||||||
import { useFocusEffect } from "expo-router";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { playSettings, playUrl } = usePlaySettings();
|
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const videoSource = useVideoSource(playSettings, api, playUrl);
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
|
|
||||||
const screenDimensions = Dimensions.get("screen");
|
|
||||||
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
}
|
|
||||||
}, [isPlaying]);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
setIsPlaying(true);
|
|
||||||
videoRef.current?.resume();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [play, stop])
|
|
||||||
);
|
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
useAndroidNavigationBar();
|
|
||||||
|
|
||||||
const onProgress = useCallback(async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: screenDimensions.width,
|
|
||||||
height: screenDimensions.height,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<StatusBar hidden />
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full"
|
|
||||||
>
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={() => {}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Controls
|
|
||||||
item={playSettings.item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
playSettings: PlaybackType | null,
|
|
||||||
api: Api | null,
|
|
||||||
playUrl?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!playSettings || !api || !playUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: playUrl,
|
|
||||||
isNetwork: false,
|
|
||||||
startPosition,
|
|
||||||
metadata: {
|
|
||||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
|
||||||
title: playSettings.item?.Name || "Unknown",
|
|
||||||
description: playSettings.item?.Overview ?? undefined,
|
|
||||||
subtitle: playSettings.item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [playSettings, api]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
import { Controls } from "@/components/video-player/Controls";
|
|
||||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
PlaybackType,
|
|
||||||
usePlaySettings,
|
|
||||||
} from "@/providers/PlaySettingsProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useFocusEffect } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, {
|
|
||||||
OnProgressData,
|
|
||||||
VideoRef,
|
|
||||||
SelectedTrack,
|
|
||||||
SelectedTrackType,
|
|
||||||
} from "react-native-video";
|
|
||||||
import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const poster = usePoster(playSettings, api);
|
|
||||||
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
|
|
||||||
const screenDimensions = Dimensions.get("screen");
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
|
||||||
async (ticks: number) => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: playSettings.item?.Id!,
|
|
||||||
audioStreamIndex: playSettings.audioIndex
|
|
||||||
? playSettings.audioIndex
|
|
||||||
: undefined,
|
|
||||||
subtitleStreamIndex: playSettings.subtitleIndex
|
|
||||||
? playSettings.subtitleIndex
|
|
||||||
: undefined,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: playSettings.item?.Id!,
|
|
||||||
audioStreamIndex: playSettings.audioIndex
|
|
||||||
? playSettings.audioIndex
|
|
||||||
: undefined,
|
|
||||||
subtitleStreamIndex: playSettings.subtitleIndex
|
|
||||||
? playSettings.subtitleIndex
|
|
||||||
: undefined,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.pause();
|
|
||||||
reportPlaybackStopped();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
await getPlaystateApi(api).onPlaybackStopped({
|
|
||||||
itemId: playSettings?.item?.Id!,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
await getPlaystateApi(api).onPlaybackStart({
|
|
||||||
itemId: playSettings?.item?.Id!,
|
|
||||||
audioStreamIndex: playSettings.audioIndex
|
|
||||||
? playSettings.audioIndex
|
|
||||||
: undefined,
|
|
||||||
subtitleStreamIndex: playSettings.subtitleIndex
|
|
||||||
? playSettings.subtitleIndex
|
|
||||||
: undefined,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = data.currentTime * 10000000;
|
|
||||||
|
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!playSettings?.item?.Id || data.currentTime === 0) return;
|
|
||||||
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: playSettings.item.Id,
|
|
||||||
audioStreamIndex: playSettings.audioIndex
|
|
||||||
? playSettings.audioIndex
|
|
||||||
: undefined,
|
|
||||||
subtitleStreamIndex: playSettings.subtitleIndex
|
|
||||||
? playSettings.subtitleIndex
|
|
||||||
: undefined,
|
|
||||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: playSessionId ? playSessionId : undefined,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [play, stop])
|
|
||||||
);
|
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
useAndroidNavigationBar();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedSubtitleTrack = useMemo(() => {
|
|
||||||
const a = playSettings?.mediaSource?.MediaStreams?.find(
|
|
||||||
(s) => s.Index === playSettings.subtitleIndex
|
|
||||||
);
|
|
||||||
console.log(a);
|
|
||||||
return a;
|
|
||||||
}, [playSettings]);
|
|
||||||
|
|
||||||
const [hlsSubTracks, setHlsSubTracks] = useState<
|
|
||||||
{
|
|
||||||
index: number;
|
|
||||||
language?: string | undefined;
|
|
||||||
selected?: boolean | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
type: any;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const selectedTextTrack = useMemo(() => {
|
|
||||||
for (let st of hlsSubTracks) {
|
|
||||||
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
|
|
||||||
return {
|
|
||||||
type: SelectedTrackType.TITLE,
|
|
||||||
value: selectedSubtitleTrack?.DisplayTitle ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [hlsSubTracks]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: screenDimensions.width,
|
|
||||||
height: screenDimensions.height,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<StatusBar hidden />
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full"
|
|
||||||
>
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={() => {}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
onTextTracks={(data) => {
|
|
||||||
console.log("onTextTracks ~", data);
|
|
||||||
setHlsSubTracks(data.textTracks as any);
|
|
||||||
}}
|
|
||||||
selectedTextTrack={selectedTextTrack}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Controls
|
|
||||||
item={playSettings.item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
playSettings: PlaybackType | null,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!playSettings?.item || !api) return undefined;
|
|
||||||
return playSettings.item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: playSettings.item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [playSettings?.item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
playSettings: PlaybackType | null,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
playUrl?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!playSettings || !api || !playUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: playUrl,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
|
||||||
title: playSettings.item?.Name || "Unknown",
|
|
||||||
description: playSettings.item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: playSettings.item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [playSettings, api, poster]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
40
app/(auth)/player/_layout.tsx
Normal file
40
app/(auth)/player/_layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SystemBars hidden />
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="direct-player"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="transcoding-player"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="music-player"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
534
app/(auth)/player/direct-player.tsx
Normal file
534
app/(auth)/player/direct-player.tsx
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
|
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
|
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||||
|
import {
|
||||||
|
PlaybackStatePayload,
|
||||||
|
ProgressUpdatePayload,
|
||||||
|
VlcPlayerViewRef,
|
||||||
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import native from "@/utils/profiles/native";
|
||||||
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
getPlaystateApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
BackHandler,
|
||||||
|
View,
|
||||||
|
AppState,
|
||||||
|
AppStateStatus,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import settings from "../(tabs)/(home)/settings";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
|
const [showControls, _setShowControls] = useState(true);
|
||||||
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
const { getDownloadedItem } = useDownload();
|
||||||
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
|
_setShowControls(show);
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
itemId,
|
||||||
|
audioIndex: audioIndexStr,
|
||||||
|
subtitleIndex: subtitleIndexStr,
|
||||||
|
mediaSourceId,
|
||||||
|
bitrateValue: bitrateValueStr,
|
||||||
|
offline: offlineStr,
|
||||||
|
} = useGlobalSearchParams<{
|
||||||
|
itemId: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
offline: string;
|
||||||
|
}>();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
|
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||||
|
const bitrateValue = bitrateValueStr
|
||||||
|
? parseInt(bitrateValueStr, 10)
|
||||||
|
: BITRATES[0].value;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: item,
|
||||||
|
isLoading: isLoadingItem,
|
||||||
|
isError: isErrorItem,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["item", itemId],
|
||||||
|
queryFn: async () => {
|
||||||
|
console.log("Offline:", offline);
|
||||||
|
if (offline) {
|
||||||
|
const item = await getDownloadedItem(itemId);
|
||||||
|
if (item) return item.item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
|
itemId,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!itemId,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: stream,
|
||||||
|
isLoading: isLoadingStreamUrl,
|
||||||
|
isError: isErrorStreamUrl,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
|
queryFn: async () => {
|
||||||
|
console.log("Offline:", offline);
|
||||||
|
if (offline) {
|
||||||
|
const data = await getDownloadedItem(itemId);
|
||||||
|
if (!data?.mediaSource) return null;
|
||||||
|
|
||||||
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
|
|
||||||
|
if (item)
|
||||||
|
return {
|
||||||
|
mediaSource: data.mediaSource,
|
||||||
|
url,
|
||||||
|
sessionId: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: audioIndex,
|
||||||
|
maxStreamingBitrate: bitrateValue,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
subtitleStreamIndex: subtitleIndex,
|
||||||
|
deviceProfile: native,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) return null;
|
||||||
|
|
||||||
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
|
||||||
|
if (!sessionId || !mediaSource || !url) {
|
||||||
|
Alert.alert("Error", "Failed to get stream url");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaSource,
|
||||||
|
sessionId,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!itemId && !!item,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const togglePlay = useCallback(async () => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
if (isPlaying) {
|
||||||
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
|
if (!offline && stream) {
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.value),
|
||||||
|
isPaused: true,
|
||||||
|
playMethod: stream.url?.includes("m3u8")
|
||||||
|
? "Transcode"
|
||||||
|
: "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Actually marked as paused");
|
||||||
|
} else {
|
||||||
|
videoRef.current?.play();
|
||||||
|
if (!offline && stream) {
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.value),
|
||||||
|
isPaused: false,
|
||||||
|
playMethod: stream?.url.includes("m3u8")
|
||||||
|
? "Transcode"
|
||||||
|
: "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
stream,
|
||||||
|
videoRef,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
offline,
|
||||||
|
progress.value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
|
if (offline) return;
|
||||||
|
|
||||||
|
const currentTimeInTicks = msToTicks(progress.value);
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: currentTimeInTicks,
|
||||||
|
playSessionId: stream?.sessionId!,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateProgressCache();
|
||||||
|
}, [api, item, mediaSourceId, stream]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
reportPlaybackStopped();
|
||||||
|
setIsPlaybackStopped(true);
|
||||||
|
videoRef.current?.stop();
|
||||||
|
}, [videoRef, reportPlaybackStopped]);
|
||||||
|
|
||||||
|
// TODO: unused should remove.
|
||||||
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
|
if (offline) return;
|
||||||
|
|
||||||
|
if (!stream) return;
|
||||||
|
await getPlaystateApi(api!).onPlaybackStart({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
||||||
|
});
|
||||||
|
}, [api, item, mediaSourceId, stream]);
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
async (data: ProgressUpdatePayload) => {
|
||||||
|
if (isSeeking.value === true) return;
|
||||||
|
if (isPlaybackStopped === true) return;
|
||||||
|
|
||||||
|
const { currentTime } = data.nativeEvent;
|
||||||
|
|
||||||
|
if (isBuffering) {
|
||||||
|
setIsBuffering(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.value = currentTime;
|
||||||
|
|
||||||
|
if (offline) return;
|
||||||
|
|
||||||
|
const currentTimeInTicks = msToTicks(currentTime);
|
||||||
|
|
||||||
|
if (!item?.Id || !stream) return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"onProgress ~",
|
||||||
|
currentTimeInTicks,
|
||||||
|
isPlaying,
|
||||||
|
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item.Id,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.floor(currentTimeInTicks),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
|
||||||
|
useWebSocket({
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
togglePlay: togglePlay,
|
||||||
|
stopPlayback: stop,
|
||||||
|
offline,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||||
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
|
|
||||||
|
if (state === "Playing") {
|
||||||
|
setIsPlaying(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "Paused") {
|
||||||
|
setIsPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
setIsPlaying(true);
|
||||||
|
setIsBuffering(false);
|
||||||
|
} else if (isBuffering) {
|
||||||
|
setIsBuffering(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startPosition = useMemo(() => {
|
||||||
|
if (offline) return 0;
|
||||||
|
|
||||||
|
return item?.UserData?.PlaybackPositionTicks
|
||||||
|
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||||
|
: 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
return async () => {
|
||||||
|
stop();
|
||||||
|
console.log("Unmounted");
|
||||||
|
};
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [appState, setAppState] = useState(AppState.currentState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
|
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||||
|
console.log("App has come to the foreground!");
|
||||||
|
// Handle app coming to the foreground
|
||||||
|
} else if (nextAppState.match(/inactive|background/)) {
|
||||||
|
console.log("App has gone to the background!");
|
||||||
|
// Handle app going to the background
|
||||||
|
if (videoRef.current && videoRef.current.pause) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAppState(nextAppState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use AppState.addEventListener and return a cleanup function
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
handleAppStateChange
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup the event listener when the component is unmounted
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [appState]);
|
||||||
|
|
||||||
|
// Preselection of audio and subtitle tracks.
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
|
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||||
|
|
||||||
|
const allSubs =
|
||||||
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
|
(sub) => sub.Type === "Subtitle"
|
||||||
|
) || [];
|
||||||
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
|
(sub) => sub.Index === subtitleIndex
|
||||||
|
);
|
||||||
|
const allAudio =
|
||||||
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
|
(audio) => audio.Type === "Audio"
|
||||||
|
) || [];
|
||||||
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
|
||||||
|
// Direct playback CASE
|
||||||
|
if (!bitrateValue) {
|
||||||
|
// If Subtitle is embedded we can use the position to select it straight away.
|
||||||
|
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
||||||
|
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
||||||
|
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
||||||
|
// If Subtitle is external we need to pass the URL to the player.
|
||||||
|
externalTrack = {
|
||||||
|
name: chosenSubtitleTrack.DisplayTitle || "",
|
||||||
|
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chosenAudioTrack)
|
||||||
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
|
} else {
|
||||||
|
// Transcoded playback CASE
|
||||||
|
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
||||||
|
externalTrack = {
|
||||||
|
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
||||||
|
DeliveryUrl: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isErrorItem || isErrorStreamUrl)
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white">Error</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
position: "relative",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VlcPlayerView
|
||||||
|
ref={videoRef}
|
||||||
|
source={{
|
||||||
|
uri: stream.url,
|
||||||
|
autoplay: true,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
externalTrack,
|
||||||
|
initOptions,
|
||||||
|
}}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
onVideoProgress={onProgress}
|
||||||
|
progressUpdateInterval={1000}
|
||||||
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
|
onVideoLoadStart={() => {}}
|
||||||
|
onVideoLoadEnd={() => {
|
||||||
|
setIsVideoLoaded(true);
|
||||||
|
}}
|
||||||
|
onVideoError={(e) => {
|
||||||
|
console.error("Video Error:", e.nativeEvent);
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
"An error occurred while playing the video. Check logs in settings."
|
||||||
|
);
|
||||||
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{videoRef.current && (
|
||||||
|
<Controls
|
||||||
|
mediaSource={stream?.mediaSource}
|
||||||
|
item={item}
|
||||||
|
videoRef={videoRef}
|
||||||
|
togglePlay={togglePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
progress={progress}
|
||||||
|
cacheProgress={cacheProgress}
|
||||||
|
isBuffering={isBuffering}
|
||||||
|
showControls={showControls}
|
||||||
|
setShowControls={setShowControls}
|
||||||
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
|
isVideoLoaded={isVideoLoaded}
|
||||||
|
play={videoRef.current?.play}
|
||||||
|
pause={videoRef.current?.pause}
|
||||||
|
seek={videoRef.current?.seekTo}
|
||||||
|
enableTrickplay={true}
|
||||||
|
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||||
|
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||||
|
offline={offline}
|
||||||
|
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||||
|
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||||
|
setAudioTrack={videoRef.current.setAudioTrack}
|
||||||
|
stop={stop}
|
||||||
|
isVlc
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoster(
|
||||||
|
item: BaseItemDto,
|
||||||
|
api: Api | null
|
||||||
|
): string | undefined {
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (!item || !api) return undefined;
|
||||||
|
return item.Type === "Audio"
|
||||||
|
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||||
|
: getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [item, api]);
|
||||||
|
|
||||||
|
return poster ?? undefined;
|
||||||
|
}
|
||||||
420
app/(auth)/player/music-player.tsx
Normal file
420
app/(auth)/player/music-player.tsx
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
getPlaystateApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { Pressable, useWindowDimensions, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
const windowDimensions = useWindowDimensions();
|
||||||
|
|
||||||
|
const firstTime = useRef(true);
|
||||||
|
|
||||||
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
|
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
itemId,
|
||||||
|
audioIndex: audioIndexStr,
|
||||||
|
subtitleIndex: subtitleIndexStr,
|
||||||
|
mediaSourceId,
|
||||||
|
bitrateValue: bitrateValueStr,
|
||||||
|
} = useLocalSearchParams<{
|
||||||
|
itemId: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
|
const subtitleIndex = subtitleIndexStr
|
||||||
|
? parseInt(subtitleIndexStr, 10)
|
||||||
|
: undefined;
|
||||||
|
const bitrateValue = bitrateValueStr
|
||||||
|
? parseInt(bitrateValueStr, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: item,
|
||||||
|
isLoading: isLoadingItem,
|
||||||
|
isError: isErrorItem,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["item", itemId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return;
|
||||||
|
const res = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!itemId && !!api,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: stream,
|
||||||
|
isLoading: isLoadingStreamUrl,
|
||||||
|
isError: isErrorStreamUrl,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["stream-url"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return;
|
||||||
|
const res = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: audioIndex,
|
||||||
|
maxStreamingBitrate: bitrateValue,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
subtitleStreamIndex: subtitleIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) return null;
|
||||||
|
|
||||||
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
|
||||||
|
if (!sessionId || !mediaSource || !url) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaSource,
|
||||||
|
sessionId,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const poster = usePoster(item, api);
|
||||||
|
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(
|
||||||
|
async (ticks: number) => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.floor(ticks),
|
||||||
|
isPaused: true,
|
||||||
|
playMethod: stream?.url.includes("m3u8")
|
||||||
|
? "Transcode"
|
||||||
|
: "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.floor(ticks),
|
||||||
|
isPaused: false,
|
||||||
|
playMethod: stream?.url.includes("m3u8")
|
||||||
|
? "Transcode"
|
||||||
|
: "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isPlaying,
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
videoRef,
|
||||||
|
settings,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
stream,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
console.log("play");
|
||||||
|
videoRef.current?.resume();
|
||||||
|
reportPlaybackStart();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
console.log("play");
|
||||||
|
videoRef.current?.pause();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
console.log("stop");
|
||||||
|
setIsPlaybackStopped(true);
|
||||||
|
videoRef.current?.pause();
|
||||||
|
reportPlaybackStopped();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const seek = useCallback(
|
||||||
|
(seconds: number) => {
|
||||||
|
videoRef.current?.seek(seconds);
|
||||||
|
},
|
||||||
|
[videoRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reportPlaybackStopped = async () => {
|
||||||
|
if (!item?.Id) return;
|
||||||
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
|
itemId: item.Id,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.floor(progress.value),
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportPlaybackStart = async () => {
|
||||||
|
if (!item?.Id) return;
|
||||||
|
await getPlaystateApi(api!).onPlaybackStart({
|
||||||
|
itemId: item?.Id,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
async (data: OnProgressData) => {
|
||||||
|
if (isSeeking.value === true) return;
|
||||||
|
if (isPlaybackStopped === true) return;
|
||||||
|
|
||||||
|
const ticks = data.currentTime * 10000000;
|
||||||
|
|
||||||
|
progress.value = secondsToTicks(data.currentTime);
|
||||||
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
setIsBuffering(data.playableDuration === 0);
|
||||||
|
|
||||||
|
if (!item?.Id || data.currentTime === 0) return;
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.round(ticks),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
item,
|
||||||
|
isPlaying,
|
||||||
|
api,
|
||||||
|
isPlaybackStopped,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
stream,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
play();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [play, stop])
|
||||||
|
);
|
||||||
|
|
||||||
|
useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
|
||||||
|
useWebSocket({
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
pauseVideo: pause,
|
||||||
|
playVideo: play,
|
||||||
|
stopPlayback: stop,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoadingItem || isLoadingStreamUrl)
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isErrorItem || isErrorStreamUrl)
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white">Error</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!item || !stream)
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white">Error</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: windowDimensions.width,
|
||||||
|
height: windowDimensions.height,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
||||||
|
<Image
|
||||||
|
source={poster}
|
||||||
|
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setShowControls(!showControls);
|
||||||
|
}}
|
||||||
|
className="absolute z-0 h-full w-full opacity-0"
|
||||||
|
>
|
||||||
|
{videoSource && (
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={videoSource}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||||
|
onProgress={onProgress}
|
||||||
|
onError={() => {}}
|
||||||
|
onLoad={() => {
|
||||||
|
if (firstTime.current === true) {
|
||||||
|
play();
|
||||||
|
firstTime.current = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
progressUpdateInterval={500}
|
||||||
|
playWhenInactive={true}
|
||||||
|
allowsExternalPlayback={true}
|
||||||
|
playInBackground={true}
|
||||||
|
pictureInPicture={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
fullscreen={false}
|
||||||
|
onPlaybackStateChanged={(state) => {
|
||||||
|
setIsPlaying(state.isPlaying);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Controls
|
||||||
|
item={item}
|
||||||
|
videoRef={videoRef}
|
||||||
|
togglePlay={togglePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
progress={progress}
|
||||||
|
cacheProgress={cacheProgress}
|
||||||
|
isBuffering={isBuffering}
|
||||||
|
showControls={showControls}
|
||||||
|
setShowControls={setShowControls}
|
||||||
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
|
enableTrickplay={false}
|
||||||
|
pause={pause}
|
||||||
|
play={play}
|
||||||
|
seek={seek}
|
||||||
|
isVlc={false}
|
||||||
|
stop={stop}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoster(
|
||||||
|
item: BaseItemDto | null | undefined,
|
||||||
|
api: Api | null
|
||||||
|
): string | undefined {
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (!item || !api) return undefined;
|
||||||
|
return item.Type === "Audio"
|
||||||
|
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||||
|
: getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [item, api]);
|
||||||
|
|
||||||
|
return poster ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoSource(
|
||||||
|
item: BaseItemDto | null | undefined,
|
||||||
|
api: Api | null,
|
||||||
|
poster: string | undefined,
|
||||||
|
url?: string | null
|
||||||
|
) {
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!item || !api || !url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPosition = item?.UserData?.PlaybackPositionTicks
|
||||||
|
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: url,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: item?.AlbumArtist ?? undefined,
|
||||||
|
title: item?.Name || "Unknown",
|
||||||
|
description: item?.Overview ?? undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: item?.Album ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [item, api, poster]);
|
||||||
|
|
||||||
|
return videoSource;
|
||||||
|
}
|
||||||
560
app/(auth)/player/transcoding-player.tsx
Normal file
560
app/(auth)/player/transcoding-player.tsx
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
|
import { TrackInfo } from "@/modules/vlc-player";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import transcoding from "@/utils/profiles/transcoding";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
getPlaystateApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import Video, {
|
||||||
|
OnProgressData,
|
||||||
|
SelectedTrack,
|
||||||
|
SelectedTrackType,
|
||||||
|
VideoRef,
|
||||||
|
} from "react-native-video";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
|
|
||||||
|
const Player = () => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
|
||||||
|
const firstTime = useRef(true);
|
||||||
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
|
const [showControls, _setShowControls] = useState(true);
|
||||||
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
|
||||||
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
|
_setShowControls(show);
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
itemId,
|
||||||
|
audioIndex: audioIndexStr,
|
||||||
|
subtitleIndex: subtitleIndexStr,
|
||||||
|
mediaSourceId,
|
||||||
|
bitrateValue: bitrateValueStr,
|
||||||
|
} = useLocalSearchParams<{
|
||||||
|
itemId: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
|
const subtitleIndex = subtitleIndexStr
|
||||||
|
? parseInt(subtitleIndexStr, 10)
|
||||||
|
: undefined;
|
||||||
|
const bitrateValue = bitrateValueStr
|
||||||
|
? parseInt(bitrateValueStr, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: item,
|
||||||
|
isLoading: isLoadingItem,
|
||||||
|
isError: isErrorItem,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["item", itemId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) {
|
||||||
|
throw new Error("No api");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
console.warn("No itemId");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
||||||
|
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
||||||
|
const {
|
||||||
|
data: stream,
|
||||||
|
isLoading: isLoadingStreamUrl,
|
||||||
|
isError: isErrorStreamUrl,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
||||||
|
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) {
|
||||||
|
throw new Error("No api");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
console.warn("No item", itemId, item);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: audioIndex,
|
||||||
|
maxStreamingBitrate: bitrateValue,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
subtitleStreamIndex: subtitleIndex,
|
||||||
|
deviceProfile: transcoding,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) return null;
|
||||||
|
|
||||||
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
|
||||||
|
if (!sessionId || !mediaSource || !url) {
|
||||||
|
console.warn("No sessionId or mediaSource or url", url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaSource,
|
||||||
|
sessionId,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!item,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const poster = usePoster(item, api);
|
||||||
|
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(async () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.floor(progress.value),
|
||||||
|
isPaused: true,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.floor(progress.value),
|
||||||
|
isPaused: false,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
videoRef,
|
||||||
|
settings,
|
||||||
|
stream,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
reportPlaybackStart();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const seek = useCallback(
|
||||||
|
(seconds: number) => {
|
||||||
|
videoRef.current?.seek(seconds);
|
||||||
|
},
|
||||||
|
[videoRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reportPlaybackStopped = async () => {
|
||||||
|
if (!item?.Id) return;
|
||||||
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
|
itemId: item.Id,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.floor(progress.value),
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
revalidateProgressCache();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
reportPlaybackStopped();
|
||||||
|
videoRef.current?.pause();
|
||||||
|
setIsPlaybackStopped(true);
|
||||||
|
}, [videoRef, reportPlaybackStopped]);
|
||||||
|
|
||||||
|
const reportPlaybackStart = async () => {
|
||||||
|
if (!item?.Id) return;
|
||||||
|
await getPlaystateApi(api!).onPlaybackStart({
|
||||||
|
itemId: item.Id,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
async (data: OnProgressData) => {
|
||||||
|
if (isSeeking.value === true) return;
|
||||||
|
if (isPlaybackStopped === true) return;
|
||||||
|
|
||||||
|
const ticks = secondsToTicks(data.currentTime);
|
||||||
|
|
||||||
|
progress.value = ticks;
|
||||||
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"onProgress ~",
|
||||||
|
ticks,
|
||||||
|
isPlaying,
|
||||||
|
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||||
|
// TODO: since playable duration is always 0 then.
|
||||||
|
setIsBuffering(data.playableDuration === 0);
|
||||||
|
|
||||||
|
if (!item?.Id || data.currentTime === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item.Id,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.round(ticks),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
item,
|
||||||
|
isPlaying,
|
||||||
|
api,
|
||||||
|
isPlaybackStopped,
|
||||||
|
isSeeking,
|
||||||
|
stream,
|
||||||
|
mediaSourceId,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
|
||||||
|
useWebSocket({
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
togglePlay: togglePlay,
|
||||||
|
stopPlayback: stop,
|
||||||
|
offline: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
||||||
|
SelectedTrack | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
const [embededTextTracks, setEmbededTextTracks] = useState<
|
||||||
|
{
|
||||||
|
index: number;
|
||||||
|
language?: string | undefined;
|
||||||
|
selected?: boolean | undefined;
|
||||||
|
title?: string | undefined;
|
||||||
|
type: any;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
||||||
|
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
||||||
|
SelectedTrack | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTextTrack === undefined) {
|
||||||
|
const subtitleHelper = new SubtitleHelper(
|
||||||
|
stream?.mediaSource.MediaStreams ?? []
|
||||||
|
);
|
||||||
|
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
||||||
|
subtitleIndex!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Most likely the subtitle is burned in.
|
||||||
|
if (embeddedTrackIndex === -1) return;
|
||||||
|
console.log(
|
||||||
|
"Setting selected text track",
|
||||||
|
subtitleIndex,
|
||||||
|
embeddedTrackIndex
|
||||||
|
);
|
||||||
|
setSelectedTextTrack({
|
||||||
|
type: SelectedTrackType.INDEX,
|
||||||
|
value: embeddedTrackIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [embededTextTracks]);
|
||||||
|
|
||||||
|
const getAudioTracks = (): TrackInfo[] => {
|
||||||
|
return audioTracks.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
index: t.index,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubtitleTracks = (): TrackInfo[] => {
|
||||||
|
return embededTextTracks.map((t) => ({
|
||||||
|
name: t.title ?? "",
|
||||||
|
index: t.index,
|
||||||
|
language: t.language,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
return async () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoadingItem || isLoadingStreamUrl)
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isErrorItem || isErrorStreamUrl)
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white">Error</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
position: "relative",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: showControls ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{videoSource ? (
|
||||||
|
<>
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={videoSource}
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||||
|
onProgress={onProgress}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error("Error playing video", e);
|
||||||
|
}}
|
||||||
|
onLoad={() => {
|
||||||
|
if (firstTime.current === true) {
|
||||||
|
play();
|
||||||
|
firstTime.current = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
progressUpdateInterval={500}
|
||||||
|
playWhenInactive={true}
|
||||||
|
allowsExternalPlayback={true}
|
||||||
|
playInBackground={true}
|
||||||
|
pictureInPicture={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
fullscreen={false}
|
||||||
|
onPlaybackStateChanged={(state) => {
|
||||||
|
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||||
|
}}
|
||||||
|
onTextTracks={(data) => {
|
||||||
|
setEmbededTextTracks(data.textTracks as any);
|
||||||
|
}}
|
||||||
|
onBuffer={(e) => {
|
||||||
|
setIsBuffering(e.isBuffering);
|
||||||
|
}}
|
||||||
|
onAudioTracks={(e) => {
|
||||||
|
console.log("onAudioTracks: ", e.audioTracks);
|
||||||
|
setAudioTracks(
|
||||||
|
e.audioTracks.map((t) => ({
|
||||||
|
index: t.index,
|
||||||
|
name: t.title ?? "",
|
||||||
|
language: t.language,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
selectedTextTrack={selectedTextTrack}
|
||||||
|
selectedAudioTrack={selectedAudioTrack}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text>No video source...</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item && (
|
||||||
|
<Controls
|
||||||
|
mediaSource={stream?.mediaSource}
|
||||||
|
videoRef={videoRef}
|
||||||
|
enableTrickplay={true}
|
||||||
|
item={item}
|
||||||
|
togglePlay={togglePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
progress={progress}
|
||||||
|
cacheProgress={cacheProgress}
|
||||||
|
isBuffering={isBuffering}
|
||||||
|
showControls={showControls}
|
||||||
|
setShowControls={setShowControls}
|
||||||
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
|
seek={seek}
|
||||||
|
play={play}
|
||||||
|
pause={pause}
|
||||||
|
stop={stop}
|
||||||
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
|
setSubtitleTrack={(i) => {
|
||||||
|
if (i === -1) {
|
||||||
|
setSelectedTextTrack({
|
||||||
|
type: SelectedTrackType.DISABLED,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedTextTrack({
|
||||||
|
type: SelectedTrackType.INDEX,
|
||||||
|
value: i,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
getAudioTracks={getAudioTracks}
|
||||||
|
setAudioTrack={(i) => {
|
||||||
|
console.log("setAudioTrack ~", i);
|
||||||
|
setSelectedAudioTrack({
|
||||||
|
type: SelectedTrackType.INDEX,
|
||||||
|
value: i,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePoster(
|
||||||
|
item: BaseItemDto | null | undefined,
|
||||||
|
api: Api | null
|
||||||
|
): string | undefined {
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (!item || !api) return undefined;
|
||||||
|
return item.Type === "Audio"
|
||||||
|
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||||
|
: getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [item, api]);
|
||||||
|
|
||||||
|
return poster ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoSource(
|
||||||
|
item: BaseItemDto | null | undefined,
|
||||||
|
api: Api | null,
|
||||||
|
poster: string | undefined,
|
||||||
|
url?: string | null
|
||||||
|
) {
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!item || !api || !url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPosition = item?.UserData?.PlaybackPositionTicks
|
||||||
|
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: url,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: item?.AlbumArtist ?? undefined,
|
||||||
|
title: item?.Name || "Unknown",
|
||||||
|
description: item?.Overview ?? undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: item?.Album ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [item, api, poster, url]);
|
||||||
|
|
||||||
|
return videoSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Player;
|
||||||
187
app/_layout.tsx
187
app/_layout.tsx
@@ -1,15 +1,17 @@
|
|||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStoraage,
|
getTokenFromStorage,
|
||||||
JellyfinProvider,
|
JellyfinProvider,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { LogProvider, writeToLog } from "@/utils/log";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
@@ -19,7 +21,6 @@ import {
|
|||||||
completeHandler,
|
completeHandler,
|
||||||
download,
|
download,
|
||||||
} from "@kesha-antonov/react-native-background-downloader";
|
} from "@kesha-antonov/react-native-background-downloader";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
@@ -31,11 +32,11 @@ import * as Notifications from "expo-notifications";
|
|||||||
import { router, Stack } from "expo-router";
|
import { router, Stack } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { StatusBar } from "expo-status-bar";
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { AppState } from "react-native";
|
import { Appearance, AppState } from "react-native";
|
||||||
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
@@ -86,7 +87,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const settingsData = await AsyncStorage.getItem("settings");
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
@@ -96,19 +97,13 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
if (!settings?.autoDownload || !url)
|
if (!settings?.autoDownload || !url)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const token = await getTokenFromStoraage();
|
const token = getTokenFromStorage();
|
||||||
const deviceId = await getOrSetDeviceId();
|
const deviceId = getOrSetDeviceId();
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
if (!token || !deviceId || !baseDirectory)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
console.log({
|
|
||||||
token,
|
|
||||||
url,
|
|
||||||
deviceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const jobs = await getAllJobsByDeviceId({
|
||||||
deviceId,
|
deviceId,
|
||||||
authHeader: token,
|
authHeader: token,
|
||||||
@@ -120,14 +115,6 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
for (let job of jobs) {
|
for (let job of jobs) {
|
||||||
if (job.status === "completed") {
|
if (job.status === "completed") {
|
||||||
const downloadUrl = url + "download/" + job.id;
|
const downloadUrl = url + "download/" + job.id;
|
||||||
console.log({
|
|
||||||
token,
|
|
||||||
deviceId,
|
|
||||||
baseDirectory,
|
|
||||||
url,
|
|
||||||
downloadUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tasks = await checkForExistingDownloads();
|
const tasks = await checkForExistingDownloads();
|
||||||
|
|
||||||
if (tasks.find((task) => task.id === job.id)) {
|
if (tasks.find((task) => task.id === job.id)) {
|
||||||
@@ -137,7 +124,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
|
|
||||||
download({
|
download({
|
||||||
id: job.id,
|
id: job.id,
|
||||||
url: url + "download/" + job.id,
|
url: downloadUrl,
|
||||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token,
|
Authorization: token,
|
||||||
@@ -191,7 +178,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
|
|
||||||
const checkAndRequestPermissions = async () => {
|
const checkAndRequestPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
const hasAskedBefore = await AsyncStorage.getItem(
|
const hasAskedBefore = storage.getString(
|
||||||
"hasAskedForNotificationPermission"
|
"hasAskedForNotificationPermission"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -206,7 +193,7 @@ const checkAndRequestPermissions = async () => {
|
|||||||
console.log("Notification permissions denied.");
|
console.log("Notification permissions denied.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
|
storage.set("hasAskedForNotificationPermission", "true");
|
||||||
} else {
|
} else {
|
||||||
console.log("Already asked for notification permissions before.");
|
console.log("Already asked for notification permissions before.");
|
||||||
}
|
}
|
||||||
@@ -231,6 +218,8 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [loaded]);
|
}, [loaded]);
|
||||||
|
|
||||||
|
Appearance.setColorScheme("dark");
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -242,6 +231,18 @@ export default function RootLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retryOnMount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||||
@@ -249,20 +250,6 @@ function Layout() {
|
|||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
useNotificationObserver();
|
useNotificationObserver();
|
||||||
|
|
||||||
const queryClientRef = useRef<QueryClient>(
|
|
||||||
new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 0,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
retryOnMount: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAndRequestPermissions();
|
checkAndRequestPermissions();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -319,73 +306,63 @@ function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClientRef.current}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<JobQueueProvider>
|
<JobQueueProvider>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<DownloadProvider>
|
<LogProvider>
|
||||||
<BottomSheetModalProvider>
|
<WebSocketProvider>
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<DownloadProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<BottomSheetModalProvider>
|
||||||
<Stack initialRouteName="/home">
|
<SystemBars style="light" hidden={false} />
|
||||||
<Stack.Screen
|
<ThemeProvider value={DarkTheme}>
|
||||||
name="(auth)/(tabs)"
|
<Stack initialRouteName="/home">
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name="(auth)/(tabs)"
|
||||||
title: "",
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
title: "",
|
||||||
<Stack.Screen
|
header: () => null,
|
||||||
name="(auth)/play-video"
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
autoHideHomeIndicator: true,
|
name="(auth)/player"
|
||||||
title: "",
|
options={{
|
||||||
animation: "fade",
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name="(auth)/play-offline-video"
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name="login"
|
||||||
autoHideHomeIndicator: true,
|
options={{
|
||||||
title: "",
|
headerShown: true,
|
||||||
animation: "fade",
|
title: "",
|
||||||
}}
|
headerTransparent: true,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/play-music"
|
<Stack.Screen name="+not-found" />
|
||||||
options={{
|
</Stack>
|
||||||
headerShown: false,
|
<Toaster
|
||||||
autoHideHomeIndicator: true,
|
duration={4000}
|
||||||
title: "",
|
toastOptions={{
|
||||||
animation: "fade",
|
style: {
|
||||||
}}
|
backgroundColor: "#262626",
|
||||||
/>
|
borderColor: "#363639",
|
||||||
<Stack.Screen
|
borderWidth: 1,
|
||||||
name="login"
|
},
|
||||||
options={{ headerShown: false, title: "Login" }}
|
titleStyle: {
|
||||||
/>
|
color: "white",
|
||||||
<Stack.Screen name="+not-found" />
|
},
|
||||||
</Stack>
|
}}
|
||||||
<Toaster
|
closeButton
|
||||||
duration={4000}
|
/>
|
||||||
toastOptions={{
|
</ThemeProvider>
|
||||||
style: {
|
</BottomSheetModalProvider>
|
||||||
backgroundColor: "#262626",
|
</DownloadProvider>
|
||||||
borderColor: "#363639",
|
</WebSocketProvider>
|
||||||
borderWidth: 1,
|
</LogProvider>
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</DownloadProvider>
|
|
||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</JobQueueProvider>
|
</JobQueueProvider>
|
||||||
@@ -395,9 +372,9 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
let items: BaseItemDto[] = downloadedItems
|
let items: BaseItemDto[] = downloadedItems
|
||||||
? JSON.parse(downloadedItems)
|
? JSON.parse(downloadedItems)
|
||||||
: [];
|
: [];
|
||||||
@@ -409,7 +386,7 @@ async function saveDownloadedItemInfo(item: BaseItemDto) {
|
|||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
storage.set("downloadedItems", JSON.stringify(items));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||||
console.error("Failed to save downloaded item information:", error);
|
console.error("Failed to save downloaded item information:", error);
|
||||||
|
|||||||
112
app/login.tsx
112
app/login.tsx
@@ -6,7 +6,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
@@ -65,6 +66,23 @@ const Login: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerTitle: serverName,
|
||||||
|
headerLeft: () =>
|
||||||
|
api?.basePath ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
removeServer();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null,
|
||||||
|
});
|
||||||
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
@@ -103,37 +121,19 @@ const Login: React.FC = () => {
|
|||||||
* - Logs errors and timeout information to the console.
|
* - Logs errors and timeout information to the console.
|
||||||
*/
|
*/
|
||||||
async function checkUrl(url: string) {
|
async function checkUrl(url: string) {
|
||||||
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
|
|
||||||
const protocols = ["https://", "http://"];
|
|
||||||
const timeout = 2000; // 2 seconds timeout for long 404 responses
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const protocol of protocols) {
|
const response = await fetch(`${url}/System/Info/Public`, {
|
||||||
const controller = new AbortController();
|
mode: "cors",
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
});
|
||||||
|
|
||||||
try {
|
if (response.ok) {
|
||||||
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
mode: "cors",
|
setServerName(data.ServerName || "");
|
||||||
signal: controller.signal,
|
return url;
|
||||||
});
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
|
||||||
setServerName(data.ServerName || "");
|
|
||||||
return `${protocol}${url}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
if (error.name === "AbortError") {
|
|
||||||
console.log(`Request to ${protocol}${url} timed out`);
|
|
||||||
} else {
|
|
||||||
console.log(`Error checking ${protocol}${url}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
@@ -159,9 +159,7 @@ const Login: React.FC = () => {
|
|||||||
const handleConnect = async (url: string) => {
|
const handleConnect = async (url: string) => {
|
||||||
url = url.trim();
|
url = url.trim();
|
||||||
|
|
||||||
const result = await checkUrl(
|
const result = await checkUrl(url);
|
||||||
url.startsWith("http") ? new URL(url).host : url
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -171,7 +169,7 @@ const Login: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServer({ address: result });
|
setServer({ address: url });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
@@ -196,38 +194,21 @@ const Login: React.FC = () => {
|
|||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1, height: "100%" }}
|
style={{ flex: 1, height: "100%" }}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
<View className="flex flex-col h-full relative items-center justify-center">
|
||||||
<View className="px-4 -mt-20">
|
<View className="px-4 -mt-20 w-full">
|
||||||
<View className="mb-4">
|
|
||||||
<Text className="text-3xl font-bold mb-1">
|
|
||||||
{serverName || "Streamyfin"}
|
|
||||||
</Text>
|
|
||||||
<View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
|
|
||||||
<Text className="">URL</Text>
|
|
||||||
<Text numberOfLines={1} className="shrink">
|
|
||||||
{api.basePath}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
color="black"
|
|
||||||
onPress={() => {
|
|
||||||
removeServer();
|
|
||||||
}}
|
|
||||||
justify="between"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons
|
|
||||||
name="arrow-back-outline"
|
|
||||||
size={18}
|
|
||||||
color={"white"}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Change server
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="text-2xl font-bold">Log in</Text>
|
<Text className="text-2xl font-bold -mb-2">
|
||||||
|
Log in
|
||||||
|
<>
|
||||||
|
{serverName ? (
|
||||||
|
<>
|
||||||
|
{" to "}
|
||||||
|
<Text className="text-purple-600">{serverName}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
@@ -286,7 +267,7 @@ const Login: React.FC = () => {
|
|||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1, height: "100%" }}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
||||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||||
@@ -301,7 +282,7 @@ const Login: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||||
<Text className="text-neutral-500">
|
<Text className="text-neutral-500">
|
||||||
Connect to your Jellyfin server
|
Enter the URL to your Jellyfin server
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Server URL"
|
placeholder="Server URL"
|
||||||
@@ -313,6 +294,9 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
<Text className="text-xs text-neutral-500">
|
||||||
|
Make sure to include http or https
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
BIN
assets/icons/house.fill.png
Normal file
BIN
assets/icons/house.fill.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/icons/list.png
Normal file
BIN
assets/icons/list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
assets/icons/magnifyingglass.png
Normal file
BIN
assets/icons/magnifyingglass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/icons/server.rack.png
Normal file
BIN
assets/icons/server.rack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -5,9 +5,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
|||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected?: number | null;
|
selected?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AudioTrackSelector: React.FC<Props> = ({
|
export const AudioTrackSelector: React.FC<Props> = ({
|
||||||
@@ -17,7 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useMemo } from "react";
|
|||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
value: number | undefined;
|
value: number | undefined;
|
||||||
height?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BITRATES: Bitrate[] = [
|
export const BITRATES: Bitrate[] = [
|
||||||
@@ -27,17 +26,14 @@ export const BITRATES: Bitrate[] = [
|
|||||||
{
|
{
|
||||||
key: "2 Mb/s",
|
key: "2 Mb/s",
|
||||||
value: 2000000,
|
value: 2000000,
|
||||||
height: 720,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "500 Kb/s",
|
key: "500 Kb/s",
|
||||||
value: 500000,
|
value: 500000,
|
||||||
height: 480,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "250 Kb/s",
|
key: "250 Kb/s",
|
||||||
value: 250000,
|
value: 250000,
|
||||||
height: 480,
|
|
||||||
},
|
},
|
||||||
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
|||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
export interface ButtonProps
|
||||||
|
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
textClassName?: string;
|
textClassName?: string;
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React, { useEffect } from "react";
|
import React, { useCallback, useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useDevices,
|
useDevices,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -39,49 +41,43 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
})();
|
})();
|
||||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
|
|
||||||
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
|
const AndroidCastButton = useCallback(
|
||||||
|
() =>
|
||||||
|
Platform.OS === "android" ? (
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
),
|
||||||
|
[Platform.OS]
|
||||||
|
);
|
||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<RoundButton
|
||||||
|
size="large"
|
||||||
|
className="mr-2"
|
||||||
|
background={false}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
}}
|
}}
|
||||||
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</TouchableOpacity>
|
</RoundButton>
|
||||||
);
|
|
||||||
|
|
||||||
if (Platform.OS === "android")
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
|
||||||
else CastContext.showCastDialog();
|
|
||||||
}}
|
|
||||||
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<RoundButton
|
||||||
|
size="large"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<BlurView
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
intensity={100}
|
</RoundButton>
|
||||||
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
|
||||||
</BlurView>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
useEpisodePoster?: boolean;
|
useEpisodePoster?: boolean;
|
||||||
size?: "small" | "normal";
|
size?: "small" | "normal";
|
||||||
|
showPlayButton?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
useEpisodePoster = false,
|
useEpisodePoster = false,
|
||||||
size = "normal",
|
size = "normal",
|
||||||
|
showPlayButton = false,
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get horrizontal poster for movie and episode, with failover to primary.
|
* Get horizontal poster for movie and episode, with failover to primary.
|
||||||
*/
|
*/
|
||||||
const url = useMemo(() => {
|
const url = useMemo(() => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -73,16 +76,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
${size === "small" ? "w-32" : "w-44"}
|
${size === "small" ? "w-32" : "w-44"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Image
|
<View className="w-full h-full flex items-center justify-center">
|
||||||
key={item.Id}
|
<Image
|
||||||
id={item.Id}
|
key={item.Id}
|
||||||
source={{
|
id={item.Id}
|
||||||
uri: url,
|
source={{
|
||||||
}}
|
uri: url,
|
||||||
cachePolicy={"memory-disk"}
|
}}
|
||||||
contentFit="cover"
|
cachePolicy={"memory-disk"}
|
||||||
className="w-full h-full"
|
contentFit="cover"
|
||||||
/>
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
{showPlayButton && (
|
||||||
|
<View className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Ionicons name="play-circle" size={40} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
{!progress && <WatchedIndicator item={item} />}
|
{!progress && <WatchedIndicator item={item} />}
|
||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import ios from "@/utils/profiles/ios";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import native from "@/utils/profiles/native";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import old from "@/utils/profiles/old";
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
|
import download from "@/utils/profiles/download";
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -17,36 +18,48 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router, useFocusEffect } from "expo-router";
|
import { Href, router, useFocusEffect } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
import { Alert, View, ViewProps } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
|
import { RoundButton } from "./RoundButton";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
item: BaseItemDto;
|
items: BaseItemDto[];
|
||||||
|
MissingDownloadIconComponent: () => React.ReactElement;
|
||||||
|
DownloadedIconComponent: () => React.ReactElement;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
size?: "default" | "large";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
export const DownloadItems: React.FC<DownloadProps> = ({
|
||||||
|
items,
|
||||||
|
MissingDownloadIconComponent,
|
||||||
|
DownloadedIconComponent,
|
||||||
|
title = "Download",
|
||||||
|
subtitle = "",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { processes, startBackgroundDownload } = useDownload();
|
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
const { startRemuxing } = useRemuxHlsToMp4();
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||||
MediaSourceInfo | undefined
|
MediaSourceInfo | undefined | null
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
@@ -56,27 +69,15 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
value: undefined,
|
value: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
useFocusEffect(
|
const userCanDownload = useMemo(
|
||||||
useCallback(() => {
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
if (!settings) return;
|
[user]
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
);
|
||||||
getDefaultPlaySettings(item, settings);
|
const usingOptimizedServer = useMemo(
|
||||||
|
() => settings?.downloadMethod === "optimized",
|
||||||
// 4. Set states
|
[settings]
|
||||||
setSelectedMediaSource(mediaSource);
|
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
|
||||||
setMaxBitrate(bitrate);
|
|
||||||
}, [item, settings])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const userCanDownload = useMemo(() => {
|
|
||||||
return user?.Policy?.EnableContentDownloading;
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bottom sheet
|
|
||||||
*/
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const handlePresentModalPress = useCallback(() => {
|
const handlePresentModalPress = useCallback(() => {
|
||||||
@@ -89,114 +90,161 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||||
* Start download
|
|
||||||
*/
|
|
||||||
const initiateDownload = useCallback(async () => {
|
|
||||||
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
|
|
||||||
throw new Error(
|
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let deviceProfile: any = iosFmp4;
|
const itemsNotDownloaded = useMemo(
|
||||||
|
() =>
|
||||||
|
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||||
|
[items, downloadedFiles]
|
||||||
|
);
|
||||||
|
|
||||||
if (settings?.deviceProfile === "Native") {
|
const allItemsDownloaded = useMemo(() => {
|
||||||
deviceProfile = native;
|
if (items.length === 0) return false;
|
||||||
} else if (settings?.deviceProfile === "Old") {
|
return itemsNotDownloaded.length === 0;
|
||||||
deviceProfile = old;
|
}, [items, itemsNotDownloaded]);
|
||||||
}
|
const itemsProcesses = useMemo(
|
||||||
|
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||||
|
[processes, itemIds]
|
||||||
|
);
|
||||||
|
|
||||||
const response = await api.axiosInstance.post(
|
const progress = useMemo(() => {
|
||||||
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
|
if (itemIds.length == 1)
|
||||||
{
|
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
||||||
DeviceProfile: deviceProfile,
|
return (
|
||||||
UserId: user.Id,
|
((itemIds.length -
|
||||||
MaxStreamingBitrate: maxBitrate.value,
|
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
||||||
StartTimeTicks: 0,
|
itemIds.length) *
|
||||||
EnableTranscoding: maxBitrate.value ? true : undefined,
|
100
|
||||||
AutoOpenLiveStream: true,
|
|
||||||
AllowVideoStreamCopy: maxBitrate.value ? false : true,
|
|
||||||
MediaSourceId: selectedMediaSource?.Id,
|
|
||||||
AudioStreamIndex: selectedAudioStream,
|
|
||||||
SubtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
}, [queue, itemsProcesses, itemIds]);
|
||||||
|
|
||||||
let url: string | undefined = undefined;
|
const itemsQueued = useMemo(() => {
|
||||||
let fileExtension: string | undefined | null = "mp4";
|
return (
|
||||||
|
itemsNotDownloaded.length > 0 &&
|
||||||
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||||
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
|
||||||
);
|
);
|
||||||
|
}, [queue, itemsNotDownloaded]);
|
||||||
|
const navigateToDownloads = () => router.push("/downloads");
|
||||||
|
|
||||||
if (!mediaSource) {
|
const onDownloadedPress = () => {
|
||||||
throw new Error("No media source");
|
const firstItem = items?.[0];
|
||||||
}
|
router.push(
|
||||||
|
firstItem.Type !== "Episode"
|
||||||
|
? "/downloads"
|
||||||
|
: ({
|
||||||
|
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||||
|
params: {
|
||||||
|
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||||
|
},
|
||||||
|
} as Href)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay) {
|
const acceptDownloadOptions = useCallback(() => {
|
||||||
if (item.MediaType === "Video") {
|
if (userCanDownload === true) {
|
||||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
||||||
} else if (item.MediaType === "Audio") {
|
throw new Error("No item id");
|
||||||
console.log("Using direct stream for audio!");
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
UserId: user.Id,
|
|
||||||
DeviceId: api.deviceInfo.id,
|
|
||||||
MaxStreamingBitrate: "140000000",
|
|
||||||
Container:
|
|
||||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
|
||||||
TranscodingContainer: "mp4",
|
|
||||||
TranscodingProtocol: "hls",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
api_key: api.accessToken,
|
|
||||||
StartTimeTicks: "0",
|
|
||||||
EnableRedirection: "true",
|
|
||||||
EnableRemoteMedia: "false",
|
|
||||||
});
|
|
||||||
url = `${api.basePath}/Audio/${
|
|
||||||
item.Id
|
|
||||||
}/universal?${searchParams.toString()}`;
|
|
||||||
}
|
}
|
||||||
} else if (mediaSource.TranscodingUrl) {
|
closeModal();
|
||||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
|
||||||
fileExtension = mediaSource.TranscodingContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url) throw new Error("No url");
|
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||||
if (!fileExtension) throw new Error("No file extension");
|
else {
|
||||||
|
queueActions.enqueue(
|
||||||
if (settings?.downloadMethod === "optimized") {
|
queue,
|
||||||
return await startBackgroundDownload(url, item, fileExtension);
|
setQueue,
|
||||||
|
...itemsNotDownloaded.map((item) => ({
|
||||||
|
id: item.Id!,
|
||||||
|
execute: async () => await initiateDownload(item),
|
||||||
|
item,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return await startRemuxing(url);
|
toast.error("You are not allowed to download files.");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
api,
|
queue,
|
||||||
item,
|
setQueue,
|
||||||
startBackgroundDownload,
|
itemsNotDownloaded,
|
||||||
user?.Id,
|
usingOptimizedServer,
|
||||||
|
userCanDownload,
|
||||||
|
maxBitrate,
|
||||||
selectedMediaSource,
|
selectedMediaSource,
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
maxBitrate,
|
|
||||||
settings?.downloadMethod,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
const initiateDownload = useCallback(
|
||||||
* Check if item is downloaded
|
async (...items: BaseItemDto[]) => {
|
||||||
*/
|
if (
|
||||||
const { downloadedFiles } = useDownload();
|
!api ||
|
||||||
|
!user?.Id ||
|
||||||
|
items.some((p) => !p.Id) ||
|
||||||
|
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mediaSource = selectedMediaSource;
|
||||||
|
let audioIndex: number | undefined = selectedAudioStream;
|
||||||
|
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||||
|
|
||||||
const isDownloaded = useMemo(() => {
|
for (const item of items) {
|
||||||
if (!downloadedFiles) return false;
|
if (itemsNotDownloaded.length > 1) {
|
||||||
|
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||||
|
item,
|
||||||
|
settings!
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
return downloadedFiles.some((file) => file.Id === item.Id);
|
const res = await getStreamUrl({
|
||||||
}, [downloadedFiles, item.Id]);
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: 0,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: audioIndex,
|
||||||
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
|
mediaSourceId: mediaSource?.Id,
|
||||||
|
subtitleStreamIndex: subtitleIndex,
|
||||||
|
deviceProfile: download,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
Alert.alert(
|
||||||
|
"Something went wrong",
|
||||||
|
"Could not get stream url from Jellyfin"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mediaSource: source, url } = res;
|
||||||
|
|
||||||
|
if (!url || !source) throw new Error("No url");
|
||||||
|
|
||||||
|
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||||
|
|
||||||
|
if (usingOptimizedServer) {
|
||||||
|
await startBackgroundDownload(url, item, source);
|
||||||
|
} else {
|
||||||
|
await startRemuxing(item, url, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
itemsNotDownloaded,
|
||||||
|
selectedMediaSource,
|
||||||
|
selectedAudioStream,
|
||||||
|
selectedSubtitleStream,
|
||||||
|
settings,
|
||||||
|
maxBitrate,
|
||||||
|
usingOptimizedServer,
|
||||||
|
startBackgroundDownload,
|
||||||
|
startRemuxing,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -208,59 +256,61 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
if (itemsNotDownloaded.length !== 1) return;
|
||||||
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
|
getDefaultPlaySettings(items[0], settings);
|
||||||
|
|
||||||
const process = useMemo(() => {
|
setSelectedMediaSource(mediaSource ?? undefined);
|
||||||
if (!processes) return null;
|
setSelectedAudioStream(audioIndex ?? 0);
|
||||||
|
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||||
|
setMaxBitrate(bitrate);
|
||||||
|
}, [items, itemsNotDownloaded, settings])
|
||||||
|
);
|
||||||
|
|
||||||
return processes.find((process) => process?.item?.Id === item.Id);
|
const renderButtonContent = () => {
|
||||||
}, [processes, item.Id]);
|
if (processes && itemsProcesses.length > 0) {
|
||||||
|
return progress === 0 ? (
|
||||||
|
<Loader />
|
||||||
|
) : (
|
||||||
|
<View className="-rotate-45">
|
||||||
|
<ProgressCircle
|
||||||
|
size={24}
|
||||||
|
fill={progress}
|
||||||
|
width={4}
|
||||||
|
tintColor="#9334E9"
|
||||||
|
backgroundColor="#bdc3c7"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
} else if (itemsQueued) {
|
||||||
|
return <Ionicons name="hourglass" size={24} color="white" />;
|
||||||
|
} else if (allItemsDownloaded) {
|
||||||
|
return <DownloadedIconComponent />;
|
||||||
|
} else {
|
||||||
|
return <MissingDownloadIconComponent />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onButtonPress = () => {
|
||||||
|
if (processes && itemsProcesses.length > 0) {
|
||||||
|
navigateToDownloads();
|
||||||
|
} else if (itemsQueued) {
|
||||||
|
navigateToDownloads();
|
||||||
|
} else if (allItemsDownloaded) {
|
||||||
|
onDownloadedPress();
|
||||||
|
} else {
|
||||||
|
handlePresentModalPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View {...props}>
|
||||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
<RoundButton size={size} onPress={onButtonPress}>
|
||||||
{...props}
|
{renderButtonContent()}
|
||||||
>
|
</RoundButton>
|
||||||
{process && process?.item.Id === item.Id ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{process.progress === 0 ? (
|
|
||||||
<Loader />
|
|
||||||
) : (
|
|
||||||
<View className="-rotate-45">
|
|
||||||
<ProgressCircle
|
|
||||||
size={24}
|
|
||||||
fill={process.progress}
|
|
||||||
width={4}
|
|
||||||
tintColor="#9334E9"
|
|
||||||
backgroundColor="#bdc3c7"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : queue.some((i) => i.id === item.Id) ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="hourglass" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : isDownloaded ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity onPress={handlePresentModalPress}>
|
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
@@ -275,68 +325,57 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||||
<Text className="font-bold text-2xl text-neutral-10">
|
<View>
|
||||||
Download options
|
<Text className="font-bold text-2xl text-neutral-100">
|
||||||
</Text>
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-neutral-300">
|
||||||
|
{subtitle || `Download ${itemsNotDownloaded.length} items`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<View className="flex flex-col space-y-2 w-full items-start">
|
<View className="flex flex-col space-y-2 w-full items-start">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
inverted
|
inverted
|
||||||
onChange={(val) => setMaxBitrate(val)}
|
onChange={setMaxBitrate}
|
||||||
selected={maxBitrate}
|
selected={maxBitrate}
|
||||||
/>
|
/>
|
||||||
<MediaSourceSelector
|
{itemsNotDownloaded.length === 1 && (
|
||||||
item={item}
|
<>
|
||||||
onChange={setSelectedMediaSource}
|
<MediaSourceSelector
|
||||||
selected={selectedMediaSource}
|
item={items[0]}
|
||||||
/>
|
onChange={setSelectedMediaSource}
|
||||||
{selectedMediaSource && (
|
selected={selectedMediaSource}
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
<AudioTrackSelector
|
|
||||||
source={selectedMediaSource}
|
|
||||||
onChange={setSelectedAudioStream}
|
|
||||||
selected={selectedAudioStream}
|
|
||||||
/>
|
/>
|
||||||
<SubtitleTrackSelector
|
{selectedMediaSource && (
|
||||||
source={selectedMediaSource}
|
<View className="flex flex-col space-y-2">
|
||||||
onChange={setSelectedSubtitleStream}
|
<AudioTrackSelector
|
||||||
selected={selectedSubtitleStream}
|
source={selectedMediaSource}
|
||||||
/>
|
onChange={setSelectedAudioStream}
|
||||||
</View>
|
selected={selectedAudioStream}
|
||||||
|
/>
|
||||||
|
<SubtitleTrackSelector
|
||||||
|
source={selectedMediaSource}
|
||||||
|
onChange={setSelectedSubtitleStream}
|
||||||
|
selected={selectedSubtitleStream}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
className="mt-auto"
|
className="mt-auto"
|
||||||
onPress={() => {
|
onPress={acceptDownloadOptions}
|
||||||
if (userCanDownload === true) {
|
|
||||||
if (!item.Id) {
|
|
||||||
throw new Error("No item id");
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
if (settings?.downloadMethod === "remux") {
|
|
||||||
queueActions.enqueue(queue, setQueue, {
|
|
||||||
id: item.Id,
|
|
||||||
execute: async () => {
|
|
||||||
await initiateDownload();
|
|
||||||
},
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
initiateDownload();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error("You are not allowed to download files.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="purple"
|
color="purple"
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
<View className="opacity-70 text-center w-full flex items-center">
|
<View className="opacity-70 text-center w-full flex items-center">
|
||||||
{settings?.downloadMethod === "optimized" ? (
|
<Text className="text-xs">
|
||||||
<Text className="text-xs">Using optimized server</Text>
|
{usingOptimizedServer
|
||||||
) : (
|
? "Using optimized server"
|
||||||
<Text className="text-xs">Using default method</Text>
|
: "Using default method"}
|
||||||
)}
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
@@ -344,3 +383,23 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DownloadSingleItem: React.FC<{
|
||||||
|
size?: "default" | "large";
|
||||||
|
item: BaseItemDto;
|
||||||
|
}> = ({ item, size = "default" }) => {
|
||||||
|
return (
|
||||||
|
<DownloadItems
|
||||||
|
size={size}
|
||||||
|
title="Download Episode"
|
||||||
|
subtitle={item.Name!}
|
||||||
|
items={[item]}
|
||||||
|
MissingDownloadIconComponent={() => (
|
||||||
|
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
DownloadedIconComponent={() => (
|
||||||
|
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row flex-wrap mt-2">
|
<View className="flex flex-row flex-wrap mt-2">
|
||||||
{genres.map((genre) => (
|
{genres.map((genre, idx) => (
|
||||||
<View
|
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1 mr-1">
|
||||||
key={genre}
|
|
||||||
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
|
|
||||||
>
|
|
||||||
<Text className="text-xs">{genre}</Text>
|
<Text className="text-xs">{genre}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
|||||||
<View className="mt-2 flex flex-col">
|
<View className="mt-2 flex flex-col">
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={2} className="">
|
<Text numberOfLines={1} className="">
|
||||||
{item.SeriesName}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
{item.Name}
|
{" - "}
|
||||||
|
{item.SeriesName}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
@@ -11,121 +11,76 @@ import { ItemImage } from "@/components/common/ItemImage";
|
|||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
|
MediaStream,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
import { Chromecast } from "./Chromecast";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
|
|
||||||
|
export type SelectedOptions = {
|
||||||
|
bitrate: Bitrate;
|
||||||
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
|
audioIndex: number | undefined;
|
||||||
|
subtitleIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||||
({ item }) => {
|
({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
|
|
||||||
const [orientation, setOrientation] = useState(
|
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (!settings) return;
|
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
|
||||||
getDefaultPlaySettings(item, settings);
|
|
||||||
|
|
||||||
setPlaySettings({
|
|
||||||
item,
|
|
||||||
bitrate,
|
|
||||||
mediaSource,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
});
|
|
||||||
}, [item, settings])
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedMediaSource = useMemo(() => {
|
|
||||||
return playSettings?.mediaSource || undefined;
|
|
||||||
}, [playSettings?.mediaSource]);
|
|
||||||
|
|
||||||
const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
|
|
||||||
setPlaySettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
mediaSource,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedAudioStream = useMemo(() => {
|
|
||||||
return playSettings?.audioIndex;
|
|
||||||
}, [playSettings?.audioIndex]);
|
|
||||||
|
|
||||||
const setSelectedAudioStream = (audioIndex: number) => {
|
|
||||||
setPlaySettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
audioIndex,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedSubtitleStream = useMemo(() => {
|
|
||||||
return playSettings?.subtitleIndex;
|
|
||||||
}, [playSettings?.subtitleIndex]);
|
|
||||||
|
|
||||||
const setSelectedSubtitleStream = (subtitleIndex: number) => {
|
|
||||||
setPlaySettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
subtitleIndex,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const maxBitrate = useMemo(() => {
|
|
||||||
return playSettings?.bitrate;
|
|
||||||
}, [playSettings?.bitrate]);
|
|
||||||
|
|
||||||
const setMaxBitrate = (bitrate: Bitrate | undefined) => {
|
|
||||||
console.log("setMaxBitrate", bitrate);
|
|
||||||
setPlaySettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
bitrate,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
|
||||||
(event) => {
|
|
||||||
setOrientation(event.orientationInfo.orientation);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
|
||||||
setOrientation(initialOrientation);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
|
|
||||||
useImageColors({ item });
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
|
SelectedOptions | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
defaultAudioIndex,
|
||||||
|
defaultBitrate,
|
||||||
|
defaultMediaSource,
|
||||||
|
defaultSubtitleIndex,
|
||||||
|
} = useDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
|
// Needs to automatically change the selected to the default values for default indexes.
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(defaultAudioIndex, defaultSubtitleIndex);
|
||||||
|
setSelectedOptions(() => ({
|
||||||
|
bitrate: defaultBitrate,
|
||||||
|
mediaSource: defaultMediaSource,
|
||||||
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
}));
|
||||||
|
}, [
|
||||||
|
defaultAudioIndex,
|
||||||
|
defaultBitrate,
|
||||||
|
defaultSubtitleIndex,
|
||||||
|
defaultMediaSource,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
@@ -135,7 +90,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<Chromecast background="blur" width={22} height={22} />
|
<Chromecast background="blur" width={22} height={22} />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<DownloadItem item={item} />
|
<DownloadSingleItem item={item} size="large" />
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -145,13 +100,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If landscape
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
|
||||||
setHeaderHeight(230);
|
setHeaderHeight(230);
|
||||||
return;
|
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Movie") setHeaderHeight(500);
|
|
||||||
else setHeaderHeight(350);
|
else setHeaderHeight(350);
|
||||||
}, [item.Type, orientation]);
|
}, [item.Type, orientation]);
|
||||||
|
|
||||||
@@ -161,7 +112,37 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||||
|
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
|
||||||
|
useState<number | undefined>(selectedOptions?.subtitleIndex);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
|
||||||
|
if (isTranscoding) {
|
||||||
|
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
|
||||||
|
const subHelper = new SubtitleHelper(
|
||||||
|
selectedOptions?.mediaSource?.MediaStreams ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
|
||||||
|
selectedOptions?.subtitleIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedOptions((prev) => ({
|
||||||
|
...prev!,
|
||||||
|
subtitleIndex: newSubtitleIndex ?? -1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
|
||||||
|
setSelectedOptions((prev) => ({
|
||||||
|
...prev!,
|
||||||
|
subtitleIndex: previouslyChosenSubtitleIndex,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setIsTranscoding(isTranscoding);
|
||||||
|
}, [selectedOptions?.bitrate]);
|
||||||
|
|
||||||
|
if (!selectedOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -214,51 +195,87 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
onChange={(val) => setMaxBitrate(val)}
|
onChange={(val) =>
|
||||||
selected={maxBitrate}
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, bitrate: val }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={selectedOptions.bitrate}
|
||||||
/>
|
/>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
item={item}
|
item={item}
|
||||||
onChange={setSelectedMediaSource}
|
onChange={(val) =>
|
||||||
selected={selectedMediaSource}
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
mediaSource: val,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={selectedOptions.mediaSource}
|
||||||
|
/>
|
||||||
|
<AudioTrackSelector
|
||||||
|
className="mr-1"
|
||||||
|
source={selectedOptions.mediaSource}
|
||||||
|
onChange={(val) => {
|
||||||
|
console.log(val);
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
audioIndex: val,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
selected={selectedOptions.audioIndex}
|
||||||
|
/>
|
||||||
|
<SubtitleTrackSelector
|
||||||
|
isTranscoding={isTranscoding}
|
||||||
|
source={selectedOptions.mediaSource}
|
||||||
|
onChange={(val) =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
subtitleIndex: val,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={selectedOptions.subtitleIndex}
|
||||||
/>
|
/>
|
||||||
{selectedMediaSource && (
|
|
||||||
<>
|
|
||||||
<AudioTrackSelector
|
|
||||||
className="mr-1"
|
|
||||||
source={selectedMediaSource}
|
|
||||||
onChange={setSelectedAudioStream}
|
|
||||||
selected={selectedAudioStream}
|
|
||||||
/>
|
|
||||||
<SubtitleTrackSelector
|
|
||||||
source={selectedMediaSource}
|
|
||||||
onChange={setSelectedSubtitleStream}
|
|
||||||
selected={selectedSubtitleStream}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlayButton item={item} url={playUrl} className="grow" />
|
<PlayButton
|
||||||
|
className="grow"
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
item={item}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className="px-4 my-4" />
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
|
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
|
{item.Type === "Episode" && (
|
||||||
|
<CurrentSeries item={item} className="mb-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && (
|
{item.People && item.People.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
{item.People.slice(0, 3).map((person) => (
|
{item.People.slice(0, 3).map((person, idx) => (
|
||||||
<MoreMoviesWithActor
|
<MoreMoviesWithActor
|
||||||
currentItem={item}
|
currentItem={item}
|
||||||
key={person.Id}
|
key={idx}
|
||||||
actorId={person.Id!}
|
actorId={person.Id!}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
@@ -266,15 +283,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
|
||||||
<CurrentSeries item={item} className="mb-4" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SimilarItems itemId={item.Id} />
|
<SimilarItems itemId={item.Id} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className="h-16"></View>
|
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
236
components/ItemTechnicalDetails.tsx
Normal file
236
components/ItemTechnicalDetails.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
MediaSourceInfo,
|
||||||
|
type MediaStream,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React, { useMemo, useRef } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Badge } from "./Badge";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import {
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetView,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
source?: MediaSourceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4 mt-2 mb-4">
|
||||||
|
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||||
|
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||||
|
<View className="flex flex-row space-x-2">
|
||||||
|
<VideoStreamInfo source={source} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-purple-600">More details</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={["80%"]}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BottomSheetScrollView>
|
||||||
|
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||||
|
<View className="">
|
||||||
|
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||||
|
<View className="flex flex-row space-x-2">
|
||||||
|
<VideoStreamInfo source={source} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="">
|
||||||
|
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||||
|
<AudioStreamInfo
|
||||||
|
audioStreams={
|
||||||
|
source?.MediaStreams?.filter(
|
||||||
|
(stream) => stream.Type === "Audio"
|
||||||
|
) || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="">
|
||||||
|
<Text className="text-lg font-bold mb-2">Subtitles</Text>
|
||||||
|
<SubtitleStreamInfo
|
||||||
|
subtitleStreams={
|
||||||
|
source?.MediaStreams?.filter(
|
||||||
|
(stream) => stream.Type === "Subtitle"
|
||||||
|
) || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SubtitleStreamInfo = ({
|
||||||
|
subtitleStreams,
|
||||||
|
}: {
|
||||||
|
subtitleStreams: MediaStream[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col">
|
||||||
|
{subtitleStreams.map((stream, index) => (
|
||||||
|
<View key={stream.Index} className="flex flex-col">
|
||||||
|
<Text className="text-xs mb-3 text-neutral-400">
|
||||||
|
{stream.DisplayTitle}
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-row flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="language-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={stream.Language}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
text={stream.Codec}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="layers-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col">
|
||||||
|
{audioStreams.map((audioStreams, index) => (
|
||||||
|
<View key={index} className="flex flex-col">
|
||||||
|
<Text className="mb-3 text-neutral-400 text-xs">
|
||||||
|
{audioStreams.DisplayTitle}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="language-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={audioStreams.Language}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name="musical-notes-outline"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
text={audioStreams.Codec}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
||||||
|
text={audioStreams.ChannelLayout}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={formatBitrate(audioStreams.BitRate)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||||
|
if (!source) return null;
|
||||||
|
|
||||||
|
const videoStream = useMemo(() => {
|
||||||
|
return source.MediaStreams?.find(
|
||||||
|
(stream) => stream.Type === "Video"
|
||||||
|
) as MediaStream;
|
||||||
|
}, [source.MediaStreams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||||
|
text={formatFileSize(source.Size)}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||||
|
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="color-palette-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={videoStream.VideoRange}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="code-working-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={videoStream.Codec}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={formatBitrate(videoStream.BitRate)}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
||||||
|
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number | null) => {
|
||||||
|
if (!bytes) return "N/A";
|
||||||
|
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
|
if (bytes === 0) return "0 Byte";
|
||||||
|
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||||
|
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBitrate = (bitrate?: number | null) => {
|
||||||
|
if (!bitrate) return "N/A";
|
||||||
|
|
||||||
|
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
||||||
|
if (bitrate === 0) return "0 bps";
|
||||||
|
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
||||||
|
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
||||||
|
};
|
||||||
@@ -26,23 +26,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||||
(x) => x.Type === "Video"
|
(x) => x.Type === "Video"
|
||||||
)?.DisplayTitle || "",
|
)?.DisplayTitle || "",
|
||||||
[item.MediaSources, selected]
|
[item, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected && item.MediaSources && item.MediaSources.length > 0) {
|
|
||||||
onChange(item.MediaSources[0]);
|
|
||||||
}
|
|
||||||
}, [item.MediaSources, selected]);
|
|
||||||
|
|
||||||
const name = (name?: string | null) => {
|
|
||||||
if (name && name.length > 40)
|
|
||||||
return (
|
|
||||||
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
|
|
||||||
);
|
|
||||||
return name;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
@@ -88,3 +74,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const name = (name?: string | null) => {
|
||||||
|
if (name && name.length > 40)
|
||||||
|
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import ios from "@/utils/profiles/ios";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useRouter } from "expo-router";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
|
CastButton,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
@@ -25,61 +30,69 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import { useRouter } from "expo-router";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item?: BaseItemDto | null;
|
item: BaseItemDto;
|
||||||
url?: string | null;
|
selectedOptions: SelectedOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_DURATION = 500;
|
const ANIMATION_DURATION = 500;
|
||||||
const MIN_PLAYBACK_WIDTH = 15;
|
const MIN_PLAYBACK_WIDTH = 15;
|
||||||
|
|
||||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
export const PlayButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
|
||||||
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
|
||||||
|
|
||||||
const startWidth = useSharedValue(0);
|
const startWidth = useSharedValue(0);
|
||||||
const targetWidth = useSharedValue(0);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(memoizedColor);
|
const endColor = useSharedValue(colorAtom);
|
||||||
const startColor = useSharedValue(memoizedColor);
|
const startColor = useSharedValue(colorAtom);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const directStream = useMemo(() => {
|
const goToPlayer = useCallback(
|
||||||
return !url?.includes("m3u8");
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
}, [url]);
|
if (!bitrateValue) {
|
||||||
|
router.push(`/player/direct-player?${q}`);
|
||||||
const onPress = async () => {
|
|
||||||
if (!url || !item) {
|
|
||||||
console.warn(
|
|
||||||
"No URL or item provided to PlayButton",
|
|
||||||
url?.slice(0, 100),
|
|
||||||
item?.Id
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
const vlcLink = "vlc://" + url;
|
|
||||||
if (vlcLink && settings?.openInVLC) {
|
|
||||||
Linking.openURL(vlcLink);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
router.push(`/player/transcoding-player?${q}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
router.push("/play-video");
|
const onPress = useCallback(async () => {
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,20 +111,36 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
|
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
await CastContext.getPlayServicesState().then(async (state) => {
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
else {
|
else {
|
||||||
// If we're opening a currently playing item, don't restart the media.
|
// Get a new URL with the Chromecast device profile:
|
||||||
// Instead just open controls.
|
const data = await getStreamUrl({
|
||||||
if (isOpeningCurrentlyPlayingMedia) {
|
api,
|
||||||
CastContext.showExpandedControls();
|
item,
|
||||||
|
deviceProfile: chromecastProfile,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||||
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data?.url) {
|
||||||
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
|
Alert.alert(
|
||||||
|
"Client error",
|
||||||
|
"Could not create stream for Chromecast"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
client
|
client
|
||||||
.loadMedia({
|
.loadMedia({
|
||||||
mediaInfo: {
|
mediaInfo: {
|
||||||
contentUrl: url,
|
contentUrl: data?.url,
|
||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
metadata:
|
metadata:
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
@@ -177,28 +206,38 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
router.push("/play-video");
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
}, [
|
||||||
|
item,
|
||||||
|
client,
|
||||||
|
settings,
|
||||||
|
api,
|
||||||
|
user,
|
||||||
|
router,
|
||||||
|
showActionSheetWithOptions,
|
||||||
|
mediaStatus,
|
||||||
|
selectedOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
const userData = memoizedItem.UserData;
|
const userData = item.UserData;
|
||||||
if (userData && userData.PlaybackPositionTicks) {
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
? Math.max(
|
? Math.max(
|
||||||
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||||
MIN_PLAYBACK_WIDTH
|
MIN_PLAYBACK_WIDTH
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}, [memoizedItem]);
|
}, [item]);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => derivedTargetWidth.value,
|
() => derivedTargetWidth.value,
|
||||||
@@ -214,7 +253,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => memoizedColor,
|
() => colorAtom,
|
||||||
(newColor) => {
|
(newColor) => {
|
||||||
endColor.value = newColor;
|
endColor.value = newColor;
|
||||||
colorChangeProgress.value = 0;
|
colorChangeProgress.value = 0;
|
||||||
@@ -223,19 +262,19 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[memoizedColor]
|
[colorAtom]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout_2 = setTimeout(() => {
|
const timeout_2 = setTimeout(() => {
|
||||||
startColor.value = memoizedColor;
|
startColor.value = colorAtom;
|
||||||
startWidth.value = targetWidth.value;
|
startWidth.value = targetWidth.value;
|
||||||
}, ANIMATION_DURATION);
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout_2);
|
clearTimeout(timeout_2);
|
||||||
};
|
};
|
||||||
}, [memoizedColor, memoizedItem]);
|
}, [colorAtom, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANIMATED STYLES
|
* ANIMATED STYLES
|
||||||
@@ -278,10 +317,11 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
disabled={!item}
|
||||||
accessibilityLabel="Play button"
|
accessibilityLabel="Play button"
|
||||||
accessibilityHint="Tap to play the media"
|
accessibilityHint="Tap to play the media"
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className="relative"
|
className={`relative`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
@@ -318,6 +358,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
{client && (
|
{client && (
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Feather name="cast" size={22} />
|
<Feather name="cast" size={22} />
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
{!client && settings?.openInVLC && (
|
{!client && settings?.openInVLC && (
|
||||||
@@ -332,7 +373,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View className="mt-2 flex flex-row items-center">
|
{/* <View className="mt-2 flex flex-row items-center">
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="information-circle"
|
name="information-circle"
|
||||||
size={12}
|
size={12}
|
||||||
@@ -342,7 +383,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
<Text className="text-neutral-500 ml-1">
|
<Text className="text-neutral-500 ml-1">
|
||||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View> */}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
|
||||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
@@ -41,52 +34,21 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["seasons"],
|
queryKey: ["seasons"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["nextUp-all"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["home"],
|
queryKey: ["home"],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View {...props}>
|
||||||
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
<RoundButton
|
||||||
{...props}
|
fillColor={item.UserData?.Played ? "primary" : undefined}
|
||||||
>
|
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
||||||
{item.UserData?.Played ? (
|
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
||||||
<TouchableOpacity
|
size="large"
|
||||||
onPress={async () => {
|
/>
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
await markAsNotPlayed({
|
|
||||||
api: api,
|
|
||||||
itemId: item?.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
invalidateQueries();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="checkmark-circle" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={async () => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
await markAsPlayed({
|
|
||||||
api: api,
|
|
||||||
item: item,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
invalidateQueries();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
114
components/RoundButton.tsx
Normal file
114
components/RoundButton.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import {
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
} from "react-native";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
onPress: () => void;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
background?: boolean;
|
||||||
|
size?: "default" | "large";
|
||||||
|
fillColor?: "primary";
|
||||||
|
hapticFeedback?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
background = true,
|
||||||
|
icon,
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
size = "default",
|
||||||
|
fillColor,
|
||||||
|
hapticFeedback = true,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
||||||
|
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (hapticFeedback) {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
onPress();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fillColor)
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={size === "large" ? 22 : 18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children ? children : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (background === false)
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={size === "large" ? 22 : 18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children ? children : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.OS === "android")
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||||
|
fillColor ? fillColorClass : "bg-neutral-800/80"
|
||||||
|
}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={size === "large" ? 22 : 18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children ? children : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={handlePress} {...props}>
|
||||||
|
<BlurView
|
||||||
|
intensity={90}
|
||||||
|
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={size === "large" ? 22 : 18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children ? children : null}
|
||||||
|
</BlurView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected?: number | null;
|
selected?: number | undefined;
|
||||||
|
isTranscoding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||||
source,
|
source,
|
||||||
onChange,
|
onChange,
|
||||||
selected,
|
selected,
|
||||||
|
isTranscoding,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(() => {
|
||||||
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||||
[source]
|
|
||||||
);
|
if (isTranscoding && Platform.OS === "ios") {
|
||||||
|
return subtitleHelper.getUniqueSubtitles();
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitleHelper.getSubtitles();
|
||||||
|
}, [source, isTranscoding]);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
() => subtitleStreams.find((x) => x.Index === selected),
|
() => subtitleStreams.find((x) => x.Index === selected),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface HorizontalScrollProps<T>
|
|||||||
> {
|
> {
|
||||||
data?: T[] | null;
|
data?: T[] | null;
|
||||||
renderItem: (item: T, index: number) => React.ReactNode;
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
|
keyExtractor?: (item: T, index: number) => string;
|
||||||
containerStyle?: ViewStyle;
|
containerStyle?: ViewStyle;
|
||||||
contentContainerStyle?: ViewStyle;
|
contentContainerStyle?: ViewStyle;
|
||||||
loadingContainerStyle?: ViewStyle;
|
loadingContainerStyle?: ViewStyle;
|
||||||
@@ -32,6 +33,7 @@ export const HorizontalScroll = forwardRef<
|
|||||||
<T,>(
|
<T,>(
|
||||||
{
|
{
|
||||||
data = [],
|
data = [],
|
||||||
|
keyExtractor,
|
||||||
renderItem,
|
renderItem,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
@@ -91,6 +93,7 @@ export const HorizontalScroll = forwardRef<
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
...contentContainerStyle,
|
...contentContainerStyle,
|
||||||
}}
|
}}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
ListEmptyComponent={() => (
|
ListEmptyComponent={() => (
|
||||||
<View className="flex-1 justify-center items-center">
|
<View className="flex-1 justify-center items-center">
|
||||||
<Text className="text-center text-gray-500">
|
<Text className="text-center text-gray-500">
|
||||||
@@ -98,6 +101,7 @@ export const HorizontalScroll = forwardRef<
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -45,6 +47,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
|||||||
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Playlist") {
|
||||||
|
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,18 +64,82 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<ContextMenu.Root>
|
||||||
onPress={() => {
|
<ContextMenu.Trigger>
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
<TouchableOpacity
|
||||||
const url = itemRouter(item, from);
|
onPress={() => {
|
||||||
// @ts-ignore
|
const url = itemRouter(item, from);
|
||||||
router.push(url);
|
// @ts-ignore
|
||||||
}}
|
router.push(url);
|
||||||
{...props}
|
}}
|
||||||
>
|
{...props}
|
||||||
{children}
|
>
|
||||||
</TouchableOpacity>
|
{children}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Content
|
||||||
|
avoidCollisions
|
||||||
|
alignOffset={0}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={false}
|
||||||
|
key={"content"}
|
||||||
|
>
|
||||||
|
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="item-1"
|
||||||
|
onSelect={() => {
|
||||||
|
markAsPlayedStatus(true);
|
||||||
|
}}
|
||||||
|
shouldDismissMenuOnSelect
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle key="item-1-title">
|
||||||
|
Mark as watched
|
||||||
|
</ContextMenu.ItemTitle>
|
||||||
|
<ContextMenu.ItemIcon
|
||||||
|
ios={{
|
||||||
|
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
||||||
|
pointSize: 18,
|
||||||
|
weight: "semibold",
|
||||||
|
scale: "medium",
|
||||||
|
hierarchicalColor: {
|
||||||
|
dark: "green", // Changed to green for "watched"
|
||||||
|
light: "green",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
androidIconName="checkmark-circle"
|
||||||
|
></ContextMenu.ItemIcon>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="item-2"
|
||||||
|
onSelect={() => {
|
||||||
|
markAsPlayedStatus(false);
|
||||||
|
}}
|
||||||
|
shouldDismissMenuOnSelect
|
||||||
|
destructive
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle key="item-2-title">
|
||||||
|
Mark as not watched
|
||||||
|
</ContextMenu.ItemTitle>
|
||||||
|
<ContextMenu.ItemIcon
|
||||||
|
ios={{
|
||||||
|
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
||||||
|
pointSize: 18, // Adjusted for better visibility
|
||||||
|
weight: "semibold",
|
||||||
|
scale: "medium",
|
||||||
|
hierarchicalColor: {
|
||||||
|
dark: "red", // Changed to red for "not watched"
|
||||||
|
light: "red",
|
||||||
|
},
|
||||||
|
// Removed paletteColors as it's not necessary in this case
|
||||||
|
}}
|
||||||
|
androidIconName="eye-slash"
|
||||||
|
></ContextMenu.ItemIcon>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { storage } from "@/utils/mmkv";
|
|||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||||
const { processes, startDownload } = useDownload();
|
const { processes } = useDownload();
|
||||||
if (processes?.length === 0)
|
if (processes?.length === 0)
|
||||||
return (
|
return (
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
@@ -40,7 +40,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
|||||||
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
||||||
<View className="space-y-2">
|
<View className="space-y-2">
|
||||||
{processes?.map((p) => (
|
{processes?.map((p) => (
|
||||||
<DownloadCard key={p.id} process={p} />
|
<DownloadCard key={p.item.Id} process={p} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -77,7 +77,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FFmpegKit.cancel();
|
FFmpegKit.cancel(Number(id));
|
||||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -85,7 +85,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
toast.success("Download canceled");
|
toast.success("Download canceled");
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
console.log(e);
|
console.error(e);
|
||||||
toast.error("Could not cancel download");
|
toast.error("Could not cancel download");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -95,7 +95,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
const length = p?.item?.RunTimeTicks || 0;
|
const length = p?.item?.RunTimeTicks || 0;
|
||||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||||
return formatTimeString(timeLeft, true);
|
return formatTimeString(timeLeft, "tick");
|
||||||
};
|
};
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
|
|||||||
47
components/downloads/DownloadSize.tsx
Normal file
47
components/downloads/DownloadSize.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { TextProps } from "react-native";
|
||||||
|
|
||||||
|
interface DownloadSizeProps extends TextProps {
|
||||||
|
items: BaseItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||||
|
items,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { downloadedFiles, getDownloadedItemSize } = useDownload();
|
||||||
|
const [size, setSize] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!downloadedFiles) return;
|
||||||
|
|
||||||
|
let s = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.Id) continue;
|
||||||
|
const size = getDownloadedItemSize(item.Id);
|
||||||
|
if (size) {
|
||||||
|
s += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSize(bytesToReadable(s));
|
||||||
|
}, [itemIds]);
|
||||||
|
|
||||||
|
const sizeText = useMemo(() => {
|
||||||
|
if (!size) return "...";
|
||||||
|
return size;
|
||||||
|
}, [size]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text className="text-xs text-neutral-500" {...props}>
|
||||||
|
{sizeText}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,37 +1,35 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import React, { useCallback, useMemo, useRef } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
ActionSheetProvider,
|
ActionSheetProvider,
|
||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
interface EpisodeCardProps extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||||
* EpisodeCard component displays an episode with action sheet options.
|
|
||||||
* @param {EpisodeCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
|
||||||
*/
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(item.Id!);
|
return storage.getString(item.Id!);
|
||||||
}, []);
|
}, [item]);
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
openFile(item);
|
openFile(item);
|
||||||
@@ -76,32 +74,30 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleOpenFile}
|
onPress={handleOpenFile}
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
className="flex flex-col w-44 mr-2"
|
key={item.Id}
|
||||||
|
className="flex flex-col mb-4"
|
||||||
>
|
>
|
||||||
{base64Image ? (
|
<View className="flex flex-row items-start mb-2">
|
||||||
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
<View className="mr-2">
|
||||||
<Image
|
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
|
||||||
source={{
|
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
<View className="shrink">
|
||||||
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
|
<Text numberOfLines={2} className="">
|
||||||
<Ionicons
|
{item.Name}
|
||||||
name="image-outline"
|
</Text>
|
||||||
size={24}
|
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||||
color="gray"
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
className="self-center mt-16"
|
</Text>
|
||||||
/>
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
||||||
|
</Text>
|
||||||
|
<DownloadSize items={[item]} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
</View>
|
||||||
<ItemCardText item={item} />
|
|
||||||
|
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||||
|
{item.Overview}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import {
|
import {
|
||||||
ActionSheetProvider,
|
ActionSheetProvider,
|
||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import { Text } from "../common/Text";
|
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
|
|
||||||
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|
||||||
interface MovieCardProps {
|
interface MovieCardProps {
|
||||||
@@ -28,7 +26,7 @@ interface MovieCardProps {
|
|||||||
*/
|
*/
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
@@ -99,7 +97,10 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ItemCardText item={item} />
|
<View className="w-28">
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
<DownloadSize items={[item]} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,55 +1,82 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { ScrollView, View } from "react-native";
|
import {TouchableOpacity, View} from "react-native";
|
||||||
import { EpisodeCard } from "./EpisodeCard";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMemo } from "react";
|
import React, {useCallback, useMemo} from "react";
|
||||||
import { SeasonPicker } from "../series/SeasonPicker";
|
import {storage} from "@/utils/mmkv";
|
||||||
|
import {Image} from "expo-image";
|
||||||
|
import {Ionicons} from "@expo/vector-icons";
|
||||||
|
import {router} from "expo-router";
|
||||||
|
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||||
|
import {useDownload} from "@/providers/DownloadProvider";
|
||||||
|
import {useActionSheet} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
||||||
const groupBySeason = useMemo(() => {
|
const { deleteItems } = useDownload();
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
items.forEach((item) => {
|
const base64Image = useMemo(() => {
|
||||||
if (!seasons[item.SeasonName!]) {
|
return storage.getString(items[0].SeriesId!);
|
||||||
seasons[item.SeasonName!] = [];
|
}, []);
|
||||||
|
|
||||||
|
const deleteSeries = useCallback(
|
||||||
|
async () => deleteItems(items),
|
||||||
|
[items]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showActionSheet = useCallback(() => {
|
||||||
|
const options = ["Delete", "Cancel"];
|
||||||
|
const destructiveButtonIndex = 0;
|
||||||
|
|
||||||
|
showActionSheetWithOptions({
|
||||||
|
options,
|
||||||
|
destructiveButtonIndex,
|
||||||
|
},
|
||||||
|
(selectedIndex) => {
|
||||||
|
if (selectedIndex == destructiveButtonIndex) {
|
||||||
|
deleteSeries();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seasons[item.SeasonName!].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(seasons).sort(
|
|
||||||
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
|
|
||||||
);
|
);
|
||||||
}, [items]);
|
}, [showActionSheetWithOptions, deleteSeries]);
|
||||||
|
|
||||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
|
|
||||||
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<TouchableOpacity
|
||||||
<View className="flex flex-row items-center justify-between px-4">
|
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
||||||
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text>
|
onLongPress={showActionSheet}
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
>
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
{base64Image ? (
|
||||||
|
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
|
||||||
|
<Text className="text-xs font-bold">{items.length}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
) : (
|
||||||
|
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
||||||
|
<Ionicons
|
||||||
|
name="image-outline"
|
||||||
|
size={24}
|
||||||
|
color="gray"
|
||||||
|
className="self-center mt-16"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text className="opacity-50 mb-2 px-4">TV-Series</Text>
|
<View className="w-28 mt-2 flex flex-col">
|
||||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
|
||||||
<View key={seasonIndex}>
|
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
|
||||||
<Text className="mb-2 font-semibold px-4">
|
<DownloadSize items={items} />
|
||||||
{seasonItems[0].SeasonName}
|
</View>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{seasonItems.sort(sortByIndex)?.map((item, index) => (
|
|
||||||
<EpisodeCard item={item} key={index} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey,
|
queryKey,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
// console.log(queryKey);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
enabled: !disabled,
|
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|||||||
44
components/inputs/Stepper.tsx
Normal file
44
components/inputs/Stepper.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {TouchableOpacity, View} from "react-native";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface StepperProps {
|
||||||
|
value: number,
|
||||||
|
step: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
onUpdate: (value: number) => void,
|
||||||
|
appendValue?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Stepper: React.FC<StepperProps> = ({
|
||||||
|
value,
|
||||||
|
step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onUpdate,
|
||||||
|
appendValue
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onUpdate(Math.max(min, value - step))}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}{appendValue}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() => onUpdate(Math.min(max, value + step))}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,12 +11,9 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacityProps, View } from "react-native";
|
||||||
import { getColors } from "react-native-image-colors";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
library: BaseItemDto;
|
library: BaseItemDto;
|
||||||
@@ -53,10 +50,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
[library]
|
[library]
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we want to use image colors for library cards
|
|
||||||
// const [color] = useAtom(itemThemeColorAtom)
|
|
||||||
// useImageColors({ url });
|
|
||||||
|
|
||||||
const { data: itemsCount } = useQuery({
|
const { data: itemsCount } = useQuery({
|
||||||
queryKey: ["library-count", library.Id],
|
queryKey: ["library-count", library.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -68,6 +61,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
});
|
});
|
||||||
return response.data.TotalRecordCount;
|
return response.data.TotalRecordCount;
|
||||||
},
|
},
|
||||||
|
staleTime: 1000 * 60 * 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log("Playing on device", data.url, item.Id);
|
console.log("Playing on device", data.url, item.Id);
|
||||||
router.push("/play-music");
|
router.push("/music-player");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -20,24 +20,37 @@ interface Props extends ViewProps {
|
|||||||
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const destinctPeople = useMemo(() => {
|
||||||
|
const people: BaseItemPerson[] = [];
|
||||||
|
item?.People?.forEach((person) => {
|
||||||
|
const existingPerson = people.find((p) => p.Id === person.Id);
|
||||||
|
if (existingPerson) {
|
||||||
|
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
|
||||||
|
} else {
|
||||||
|
people.push(person);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return people;
|
||||||
|
}, [item?.People]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="flex flex-col">
|
<View {...props} className="flex flex-col">
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
keyExtractor={(i, idx) => i.Id.toString()}
|
||||||
height={247}
|
height={247}
|
||||||
data={item?.People || []}
|
data={destinctPeople}
|
||||||
renderItem={(item, index) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/actors/${item.Id}`);
|
router.push(`/actors/${i.Id}`);
|
||||||
}}
|
}}
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
|
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
|
||||||
<Text className="mt-2">{item.Name}</Text>
|
<Text className="mt-2">{i.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{item.Role}</Text>
|
<Text className="text-xs opacity-50">{i.Role}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
121
components/series/SeasonDropdown.tsx
Normal file
121
components/series/SeasonDropdown.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
seasons: BaseItemDto[];
|
||||||
|
initialSeasonIndex?: number;
|
||||||
|
state: SeasonIndexState;
|
||||||
|
onSelect: (season: BaseItemDto) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeasonKeys = {
|
||||||
|
id: keyof BaseItemDto;
|
||||||
|
title: keyof BaseItemDto;
|
||||||
|
index: keyof BaseItemDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeasonIndexState = {
|
||||||
|
[seriesId: string]: number | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SeasonDropdown: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
seasons,
|
||||||
|
initialSeasonIndex,
|
||||||
|
state,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
const keys = useMemo<SeasonKeys>(
|
||||||
|
() =>
|
||||||
|
item.Type === "Episode"
|
||||||
|
? {
|
||||||
|
id: "ParentId",
|
||||||
|
title: "SeasonName",
|
||||||
|
index: "ParentIndexNumber",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: "Id",
|
||||||
|
title: "Name",
|
||||||
|
index: "IndexNumber",
|
||||||
|
},
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
const seasonIndex = useMemo(
|
||||||
|
() => state[(item[keys.id] as string) ?? ""],
|
||||||
|
[state]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||||
|
let initialIndex: number | undefined;
|
||||||
|
|
||||||
|
if (initialSeasonIndex !== undefined) {
|
||||||
|
// Use the provided initialSeasonIndex if it exists in the seasons
|
||||||
|
const seasonExists = seasons.some(
|
||||||
|
(season: any) => season[keys.index] === initialSeasonIndex
|
||||||
|
);
|
||||||
|
if (seasonExists) {
|
||||||
|
initialIndex = initialSeasonIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex === undefined) {
|
||||||
|
// Fall back to the previous logic if initialIndex is not set
|
||||||
|
const season1 = seasons.find((season: any) => season[keys.index] === 1);
|
||||||
|
const season0 = seasons.find((season: any) => season[keys.index] === 0);
|
||||||
|
const firstSeason = season1 || season0 || seasons[0];
|
||||||
|
onSelect(firstSeason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex !== undefined) {
|
||||||
|
const initialSeason = seasons.find(
|
||||||
|
(season: any) => season[keys.index] === initialIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
if (initialSeason) onSelect(initialSeason!);
|
||||||
|
else throw Error("Initial index could not be found!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]);
|
||||||
|
|
||||||
|
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||||
|
Number(a[keys.index]) - Number(b[keys.index]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text>Season {seasonIndex}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
||||||
|
{seasons?.sort(sortByIndex).map((season: any) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={season[keys.title]}
|
||||||
|
onSelect={() => onSelect(season)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{season[keys.title]}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,30 +2,27 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { DownloadItem } from "../DownloadItem";
|
import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
initialSeasonIndex?: number;
|
initialSeasonIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SeasonIndexState = {
|
|
||||||
[seriesId: string]: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||||
@@ -35,8 +32,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { data: seasons } = useQuery({
|
const { data: seasons } = useQuery({
|
||||||
queryKey: ["seasons", item.Id],
|
queryKey: ["seasons", item.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -61,37 +56,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
enabled: !!api && !!user?.Id && !!item.Id,
|
enabled: !!api && !!user?.Id && !!item.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
|
||||||
let initialIndex: number | undefined;
|
|
||||||
|
|
||||||
if (initialSeasonIndex !== undefined) {
|
|
||||||
// Use the provided initialSeasonIndex if it exists in the seasons
|
|
||||||
const seasonExists = seasons.some(
|
|
||||||
(season: any) => season.IndexNumber === initialSeasonIndex
|
|
||||||
);
|
|
||||||
if (seasonExists) {
|
|
||||||
initialIndex = initialSeasonIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialIndex === undefined) {
|
|
||||||
// Fall back to the previous logic if initialIndex is not set
|
|
||||||
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
|
|
||||||
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
|
|
||||||
const firstSeason = season1 || season0 || seasons[0];
|
|
||||||
initialIndex = firstSeason.IndexNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialIndex !== undefined) {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.Id ?? ""]: initialIndex,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
|
|
||||||
|
|
||||||
const selectedSeasonId: string | null = useMemo(
|
const selectedSeasonId: string | null = useMemo(
|
||||||
() =>
|
() =>
|
||||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||||
@@ -148,39 +112,30 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
minHeight: 144 * nrOfEpisodes,
|
minHeight: 144 * nrOfEpisodes,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<View className="flex flex-row justify-start items-center px-4">
|
||||||
<DropdownMenu.Trigger>
|
<SeasonDropdown
|
||||||
<View className="flex flex-row px-4">
|
item={item}
|
||||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
seasons={seasons}
|
||||||
<Text>Season {seasonIndex}</Text>
|
state={seasonIndexState}
|
||||||
</TouchableOpacity>
|
onSelect={(season) => {
|
||||||
</View>
|
setSeasonIndexState((prev) => ({
|
||||||
</DropdownMenu.Trigger>
|
...prev,
|
||||||
<DropdownMenu.Content
|
[item.Id ?? ""]: season.IndexNumber,
|
||||||
loop={true}
|
}));
|
||||||
side="bottom"
|
}}
|
||||||
align="start"
|
/>
|
||||||
alignOffset={0}
|
<DownloadItems
|
||||||
avoidCollisions={true}
|
title="Download Season"
|
||||||
collisionPadding={8}
|
className="ml-2"
|
||||||
sideOffset={8}
|
items={episodes || []}
|
||||||
>
|
MissingDownloadIconComponent={() => (
|
||||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
<Ionicons name="download" size={20} color="white" />
|
||||||
{seasons?.map((season: any) => (
|
)}
|
||||||
<DropdownMenu.Item
|
DownloadedIconComponent={() => (
|
||||||
key={season.Name}
|
<Ionicons name="download" size={20} color="#9333ea" />
|
||||||
onSelect={() => {
|
)}
|
||||||
setSeasonIndexState((prev) => ({
|
/>
|
||||||
...prev,
|
</View>
|
||||||
[item.Id ?? ""]: season.IndexNumber,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
<View className="px-4 flex flex-col my-4">
|
<View className="px-4 flex flex-col my-4">
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<View
|
<View
|
||||||
@@ -218,7 +173,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="self-start ml-auto -mt-0.5">
|
<View className="self-start ml-auto -mt-0.5">
|
||||||
<DownloadItem item={e} />
|
<DownloadSingleItem item={e} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
114
components/settings/AudioToggles.tsx
Normal file
114
components/settings/AudioToggles.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { useMedia } from "./MediaContext";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const media = useMedia();
|
||||||
|
const { settings, updateSettings } = media;
|
||||||
|
const cultures = media.cultures;
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||||
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Audio language</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose a default audio language.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text>
|
||||||
|
{settings?.defaultAudioLanguage?.DisplayName || "None"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={"none-audio"}
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultAudioLanguage: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{cultures?.map((l) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultAudioLanguage: l,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{l.DisplayName}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">Use Default Audio</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Play default audio track regardless of language.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.playDefaultAudioTrack}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ playDefaultAudioTrack: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">
|
||||||
|
Set Audio Track From Previous Item
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||||
|
Try to set the audio track to the closest match to the last
|
||||||
|
video.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.rememberAudioSelections}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ rememberAudioSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
154
components/settings/MediaContext.tsx
Normal file
154
components/settings/MediaContext.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import {
|
||||||
|
CultureDto,
|
||||||
|
UserDto,
|
||||||
|
UserConfiguration,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
interface MediaContextType {
|
||||||
|
settings: Settings | null;
|
||||||
|
updateSettings: (update: Partial<Settings>) => void;
|
||||||
|
user: UserDto | undefined;
|
||||||
|
cultures: CultureDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaContext = createContext<MediaContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useMedia = () => {
|
||||||
|
const context = useContext(MediaContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useMedia must be used within a MediaProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const updateSetingsWrapper = (update: Partial<Settings>) => {
|
||||||
|
const updateUserConfiguration = async (
|
||||||
|
update: Partial<UserConfiguration>
|
||||||
|
) => {
|
||||||
|
if (api && user) {
|
||||||
|
try {
|
||||||
|
await getUserApi(api).updateUserConfiguration({
|
||||||
|
userConfiguration: {
|
||||||
|
...user.Configuration,
|
||||||
|
...update,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["authUser"] });
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSettings(update);
|
||||||
|
|
||||||
|
console.log("update", update);
|
||||||
|
|
||||||
|
let updatePayload = {
|
||||||
|
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
|
||||||
|
PlayDefaultAudioTrack:
|
||||||
|
update?.playDefaultAudioTrack ?? settings?.playDefaultAudioTrack,
|
||||||
|
RememberAudioSelections:
|
||||||
|
update?.rememberAudioSelections ?? settings?.rememberAudioSelections,
|
||||||
|
RememberSubtitleSelections:
|
||||||
|
update?.rememberSubtitleSelections ??
|
||||||
|
settings?.rememberSubtitleSelections,
|
||||||
|
} as Partial<UserConfiguration>;
|
||||||
|
|
||||||
|
updatePayload.AudioLanguagePreference =
|
||||||
|
update?.defaultAudioLanguage === null
|
||||||
|
? ""
|
||||||
|
: update?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
|
||||||
|
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
updatePayload.SubtitleLanguagePreference =
|
||||||
|
update?.defaultSubtitleLanguage === null
|
||||||
|
? ""
|
||||||
|
: update?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||||
|
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
console.log("updatePayload", updatePayload);
|
||||||
|
|
||||||
|
updateUserConfiguration(updatePayload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ["authUser"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return;
|
||||||
|
const userApi = await getUserApi(api).getCurrentUser();
|
||||||
|
return userApi.data;
|
||||||
|
},
|
||||||
|
enabled: !!api,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: cultures = [], isFetched: isCulturesFetched } = useQuery({
|
||||||
|
queryKey: ["cultures"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const localizationApi = await getLocalizationApi(api).getCultures();
|
||||||
|
const cultures = localizationApi.data;
|
||||||
|
return cultures;
|
||||||
|
},
|
||||||
|
enabled: !!api,
|
||||||
|
staleTime: 43200000, // 12 hours
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set default settings from user configuration.s
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || cultures.length === 0) return;
|
||||||
|
const userSubtitlePreference =
|
||||||
|
user?.Configuration?.SubtitleLanguagePreference;
|
||||||
|
const userAudioPreference = user?.Configuration?.AudioLanguagePreference;
|
||||||
|
|
||||||
|
const subtitlePreference = cultures.find(
|
||||||
|
(x) => x.ThreeLetterISOLanguageName === userSubtitlePreference
|
||||||
|
);
|
||||||
|
const audioPreference = cultures.find(
|
||||||
|
(x) => x.ThreeLetterISOLanguageName === userAudioPreference
|
||||||
|
);
|
||||||
|
|
||||||
|
updateSettings({
|
||||||
|
defaultSubtitleLanguage: subtitlePreference,
|
||||||
|
defaultAudioLanguage: audioPreference,
|
||||||
|
subtitleMode: user?.Configuration?.SubtitleMode,
|
||||||
|
playDefaultAudioTrack: user?.Configuration?.PlayDefaultAudioTrack,
|
||||||
|
rememberAudioSelections: user?.Configuration?.RememberAudioSelections,
|
||||||
|
rememberSubtitleSelections:
|
||||||
|
user?.Configuration?.RememberSubtitleSelections,
|
||||||
|
});
|
||||||
|
}, [user, isCulturesFetched]);
|
||||||
|
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MediaContext.Provider
|
||||||
|
value={{
|
||||||
|
settings,
|
||||||
|
updateSettings: updateSetingsWrapper,
|
||||||
|
user,
|
||||||
|
cultures,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MediaContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { LANGUAGES } from "@/constants/Languages";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -15,113 +13,6 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
<Text className="text-lg font-bold mb-2">Media</Text>
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Audio language</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose a default audio language.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>{settings?.defaultAudioLanguage?.label || "None"}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"none-audio"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultAudioLanguage: null,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{LANGUAGES.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={l.value}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultAudioLanguage: l,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Subtitle language</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose a default subtitle language.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>
|
|
||||||
{settings?.defaultSubtitleLanguage?.label || "None"}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"none-subs"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultSubtitleLanguage: null,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{LANGUAGES.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={l.value}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultSubtitleLanguage: l,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import {
|
|||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
userAtom,
|
userAtom,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import {
|
||||||
|
ScreenOrientationEnum,
|
||||||
|
Settings,
|
||||||
|
useSettings,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
registerBackgroundFetchAsync,
|
registerBackgroundFetchAsync,
|
||||||
unregisterBackgroundFetchAsync,
|
unregisterBackgroundFetchAsync,
|
||||||
} from "@/utils/background-tasks";
|
} from "@/utils/background-tasks";
|
||||||
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
@@ -18,7 +23,6 @@ import * as TaskManager from "expo-task-manager";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -32,8 +36,10 @@ import { Input } from "../common/Input";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { MediaToggles } from "./MediaToggles";
|
import { MediaToggles } from "./MediaToggles";
|
||||||
import axios from "axios";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
import { MediaProvider } from "./MediaContext";
|
||||||
|
import { SubtitleToggles } from "./SubtitleToggles";
|
||||||
|
import { AudioToggles } from "./AudioToggles";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -64,7 +70,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
if (settings?.autoDownload === true && !registered) {
|
if (settings?.autoDownload === true && !registered) {
|
||||||
registerBackgroundFetchAsync();
|
registerBackgroundFetchAsync();
|
||||||
toast.success("Background downlodas enabled");
|
toast.success("Background downloads enabled");
|
||||||
} else if (settings?.autoDownload === false && registered) {
|
} else if (settings?.autoDownload === false && registered) {
|
||||||
unregisterBackgroundFetchAsync();
|
unregisterBackgroundFetchAsync();
|
||||||
toast.info("Background downloads disabled");
|
toast.info("Background downloads disabled");
|
||||||
@@ -121,7 +127,11 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View> */}
|
</View> */}
|
||||||
|
|
||||||
<MediaToggles />
|
<MediaProvider>
|
||||||
|
<MediaToggles />
|
||||||
|
<AudioToggles />
|
||||||
|
<SubtitleToggles />
|
||||||
|
</MediaProvider>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||||
@@ -248,22 +258,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
|
||||||
<Text className="text-xs opacity-50 shrink">
|
|
||||||
Open all videos in VLC instead of the default player. This
|
|
||||||
requries VLC to be installed on the phone.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.openInVLC}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
@@ -334,79 +328,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Force direct play</Text>
|
|
||||||
<Text className="text-xs opacity-50 shrink">
|
|
||||||
This will always request direct play. This is good if you want
|
|
||||||
to try to stream movies you think the device supports.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.forceDirectPlay}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ forceDirectPlay: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Device profile</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
A profile used for deciding what audio and video codecs the
|
|
||||||
device supports.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>{settings.deviceProfile}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Expo" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Native" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="3"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Old" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
@@ -494,6 +415,31 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="shrink">
|
||||||
|
<Text className="font-semibold">Show Custom Menu Links</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Show custom menu links defined inside your Jellyfin web
|
||||||
|
config.json file
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
Linking.openURL(
|
||||||
|
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-purple-600">More info</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.showCustomMenuLinks}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ showCustomMenuLinks: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -554,7 +500,50 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
<View
|
||||||
|
pointerEvents={
|
||||||
|
settings.downloadMethod === "remux" ? "auto" : "none"
|
||||||
|
}
|
||||||
|
className={`
|
||||||
|
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||||
|
${
|
||||||
|
settings.downloadMethod === "remux"
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Remux max download</Text>
|
||||||
|
<Text className="text-xs opacity-50 shrink">
|
||||||
|
This is the total media you want to be able to download at the
|
||||||
|
same time.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Stepper
|
||||||
|
value={settings.remuxConcurrentLimit}
|
||||||
|
step={1}
|
||||||
|
min={1}
|
||||||
|
max={4}
|
||||||
|
onUpdate={(value) =>
|
||||||
|
updateSettings({
|
||||||
|
remuxConcurrentLimit:
|
||||||
|
value as Settings["remuxConcurrentLimit"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
pointerEvents={
|
||||||
|
settings.downloadMethod === "optimized" ? "auto" : "none"
|
||||||
|
}
|
||||||
|
className={`
|
||||||
|
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||||
|
${
|
||||||
|
settings.downloadMethod === "optimized"
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Auto download</Text>
|
<Text className="font-semibold">Auto download</Text>
|
||||||
<Text className="text-xs opacity-50 shrink">
|
<Text className="text-xs opacity-50 shrink">
|
||||||
|
|||||||
191
components/settings/SubtitleToggles.tsx
Normal file
191
components/settings/SubtitleToggles.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { useMedia } from "./MediaContext";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const media = useMedia();
|
||||||
|
const { settings, updateSettings } = media;
|
||||||
|
const cultures = media.cultures;
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
const subtitleModes = [
|
||||||
|
SubtitlePlaybackMode.Default,
|
||||||
|
SubtitlePlaybackMode.Smart,
|
||||||
|
SubtitlePlaybackMode.OnlyForced,
|
||||||
|
SubtitlePlaybackMode.Always,
|
||||||
|
SubtitlePlaybackMode.None,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold mb-2">Subtitle</Text>
|
||||||
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Subtitle language</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose a default subtitle language.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text>
|
||||||
|
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={"none-subs"}
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultSubtitleLanguage: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{cultures?.map((l) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultSubtitleLanguage: l,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{l.DisplayName}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Subtitle Mode</Text>
|
||||||
|
<Text className="text-xs opacity-50 mr-2">
|
||||||
|
Subtitles are loaded based on the default and forced flags in the
|
||||||
|
embedded metadata. Language preferences are considered when
|
||||||
|
multiple options are available.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
|
||||||
|
{subtitleModes?.map((l) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={l}
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
subtitleMode: l,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">
|
||||||
|
Set Subtitle Track From Previous Item
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||||
|
Try to set the subtitle track to the closest match to the last
|
||||||
|
video.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.rememberSubtitleSelections}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Subtitle Size</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose a default subtitle size for direct play (only works for
|
||||||
|
some subtitle formats).
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
subtitleSize: Math.max(0, settings.subtitleSize - 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
|
{settings.subtitleSize}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
subtitleSize: Math.min(120, settings.subtitleSize + 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
|
import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||||
|
import { ParamListBase, RouteProp } from "@react-navigation/native";
|
||||||
|
|
||||||
const commonScreenOptions = {
|
type ICommonScreenOptions =
|
||||||
|
| NativeStackNavigationOptions
|
||||||
|
| ((prop: {
|
||||||
|
route: RouteProp<ParamListBase, string>;
|
||||||
|
navigation: any;
|
||||||
|
}) => NativeStackNavigationOptions);
|
||||||
|
|
||||||
|
const commonScreenOptions: ICommonScreenOptions = {
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerTransparent: true,
|
headerTransparent: true,
|
||||||
@@ -17,5 +26,5 @@ const routes = [
|
|||||||
"series/[id]",
|
"series/[id]",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const nestedTabPageScreenOptions: { [key: string]: any } =
|
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
|
||||||
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
||||||
|
|||||||
@@ -1,529 +0,0 @@
|
|||||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
|
||||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
|
||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { formatTimeString, ticksToSeconds } from "@/utils/time";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Dimensions,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import Animated, {
|
|
||||||
runOnJS,
|
|
||||||
SharedValue,
|
|
||||||
useAnimatedReaction,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { VideoRef } from "react-native-video";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { Loader } from "../Loader";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: BaseItemDto;
|
|
||||||
videoRef: React.MutableRefObject<VideoRef | null>;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isSeeking: SharedValue<boolean>;
|
|
||||||
cacheProgress: SharedValue<number>;
|
|
||||||
progress: SharedValue<number>;
|
|
||||||
isBuffering: boolean;
|
|
||||||
showControls: boolean;
|
|
||||||
ignoreSafeAreas?: boolean;
|
|
||||||
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
enableTrickplay?: boolean;
|
|
||||||
togglePlay: (ticks: number) => void;
|
|
||||||
setShowControls: (shown: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Controls: React.FC<Props> = ({
|
|
||||||
item,
|
|
||||||
videoRef,
|
|
||||||
togglePlay,
|
|
||||||
isPlaying,
|
|
||||||
isSeeking,
|
|
||||||
progress,
|
|
||||||
isBuffering,
|
|
||||||
cacheProgress,
|
|
||||||
showControls,
|
|
||||||
setShowControls,
|
|
||||||
ignoreSafeAreas,
|
|
||||||
setIgnoreSafeAreas,
|
|
||||||
enableTrickplay = true,
|
|
||||||
}) => {
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { setPlaySettings } = usePlaySettings();
|
|
||||||
|
|
||||||
const windowDimensions = Dimensions.get("window");
|
|
||||||
|
|
||||||
const op = useSharedValue<number>(1);
|
|
||||||
const tr = useSharedValue<number>(10);
|
|
||||||
const animatedStyles = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: op.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const animatedTopStyles = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: op.value,
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: -tr.value,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const animatedBottomStyles = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: op.value,
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: tr.value,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showControls || isBuffering) {
|
|
||||||
op.value = withTiming(1, { duration: 200 });
|
|
||||||
tr.value = withTiming(0, { duration: 200 });
|
|
||||||
} else {
|
|
||||||
op.value = withTiming(0, { duration: 200 });
|
|
||||||
tr.value = withTiming(10, { duration: 200 });
|
|
||||||
}
|
|
||||||
}, [showControls, isBuffering]);
|
|
||||||
|
|
||||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
|
||||||
item,
|
|
||||||
enableTrickplay
|
|
||||||
);
|
|
||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0); // Seconds
|
|
||||||
const [remainingTime, setRemainingTime] = useState(0); // Seconds
|
|
||||||
|
|
||||||
const min = useSharedValue(0);
|
|
||||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
|
||||||
|
|
||||||
const wasPlayingRef = useRef(false);
|
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
|
||||||
(currentProgress: number, maxValue: number) => {
|
|
||||||
const current = ticksToSeconds(currentProgress);
|
|
||||||
const remaining = ticksToSeconds(maxValue - currentProgress);
|
|
||||||
|
|
||||||
setCurrentTime(current);
|
|
||||||
setRemainingTime(remaining);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
|
||||||
item.Id,
|
|
||||||
currentTime,
|
|
||||||
videoRef
|
|
||||||
);
|
|
||||||
|
|
||||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
|
||||||
item.Id,
|
|
||||||
currentTime,
|
|
||||||
videoRef
|
|
||||||
);
|
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
|
||||||
if (!previousItem || !settings) return;
|
|
||||||
|
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
|
||||||
getDefaultPlaySettings(previousItem, settings);
|
|
||||||
|
|
||||||
setPlaySettings({
|
|
||||||
item: previousItem,
|
|
||||||
bitrate,
|
|
||||||
mediaSource,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.replace("/play-video");
|
|
||||||
}, [previousItem, settings]);
|
|
||||||
|
|
||||||
const goToNextItem = useCallback(() => {
|
|
||||||
if (!nextItem || !settings) return;
|
|
||||||
|
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
|
||||||
getDefaultPlaySettings(nextItem, settings);
|
|
||||||
|
|
||||||
setPlaySettings({
|
|
||||||
item: nextItem,
|
|
||||||
bitrate,
|
|
||||||
mediaSource,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.replace("/play-video");
|
|
||||||
}, [nextItem, settings]);
|
|
||||||
|
|
||||||
useAnimatedReaction(
|
|
||||||
() => ({
|
|
||||||
progress: progress.value,
|
|
||||||
max: max.value,
|
|
||||||
isSeeking: isSeeking.value,
|
|
||||||
}),
|
|
||||||
(result) => {
|
|
||||||
if (result.isSeeking === false) {
|
|
||||||
runOnJS(updateTimes)(result.progress, result.max);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updateTimes]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (item) {
|
|
||||||
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
|
|
||||||
max.value = item.RunTimeTicks || 0;
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const toggleControls = () => setShowControls(!showControls);
|
|
||||||
|
|
||||||
const handleSliderComplete = useCallback((value: number) => {
|
|
||||||
progress.value = value;
|
|
||||||
isSeeking.value = false;
|
|
||||||
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
|
|
||||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSliderChange = (value: number) => {
|
|
||||||
calculateTrickplayUrl(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSliderStart = useCallback(() => {
|
|
||||||
if (showControls === false) return;
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
videoRef.current?.pause();
|
|
||||||
isSeeking.value = true;
|
|
||||||
}, [showControls, isPlaying]);
|
|
||||||
|
|
||||||
const handleSkipBackward = useCallback(async () => {
|
|
||||||
console.log("handleSkipBackward");
|
|
||||||
if (!settings?.rewindSkipTime) return;
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
try {
|
|
||||||
const curr = await videoRef.current?.getCurrentPosition();
|
|
||||||
if (curr !== undefined) {
|
|
||||||
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
|
|
||||||
setTimeout(() => {
|
|
||||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
|
||||||
}
|
|
||||||
}, [settings, isPlaying]);
|
|
||||||
|
|
||||||
const handleSkipForward = useCallback(async () => {
|
|
||||||
console.log("handleSkipForward");
|
|
||||||
if (!settings?.forwardSkipTime) return;
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
try {
|
|
||||||
const curr = await videoRef.current?.getCurrentPosition();
|
|
||||||
if (curr !== undefined) {
|
|
||||||
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
|
|
||||||
setTimeout(() => {
|
|
||||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
|
||||||
}
|
|
||||||
}, [settings, isPlaying]);
|
|
||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: windowDimensions.width,
|
|
||||||
height: windowDimensions.height,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: insets.bottom + 97,
|
|
||||||
right: insets.right,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className={`z-10 p-4
|
|
||||||
${showSkipButton ? "opacity-100" : "opacity-0"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={skipIntro}
|
|
||||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
|
||||||
>
|
|
||||||
<Text className="text-white">Skip Intro</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: insets.bottom + 94,
|
|
||||||
right: insets.right,
|
|
||||||
height: 70,
|
|
||||||
}}
|
|
||||||
pointerEvents={showSkipCreditButton ? "auto" : "none"}
|
|
||||||
className={`z-10 p-4 ${
|
|
||||||
showSkipCreditButton ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={skipCredit}
|
|
||||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
|
||||||
>
|
|
||||||
<Text className="text-white">Skip Credits</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
toggleControls();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: windowDimensions.width + 100,
|
|
||||||
height: windowDimensions.height + 100,
|
|
||||||
},
|
|
||||||
animatedStyles,
|
|
||||||
]}
|
|
||||||
className={`bg-black/50 z-0`}
|
|
||||||
></Animated.View>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: windowDimensions.width,
|
|
||||||
height: windowDimensions.height,
|
|
||||||
}}
|
|
||||||
pointerEvents="none"
|
|
||||||
className={`flex flex-col items-center justify-center
|
|
||||||
${isBuffering ? "opacity-100" : "opacity-0"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: insets.top,
|
|
||||||
right: insets.right,
|
|
||||||
},
|
|
||||||
animatedTopStyles,
|
|
||||||
]}
|
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
|
||||||
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={toggleIgnoreSafeAreas}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
|
||||||
size={24}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
width: windowDimensions.width - insets.left - insets.right,
|
|
||||||
maxHeight: windowDimensions.height,
|
|
||||||
left: insets.left,
|
|
||||||
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
|
|
||||||
},
|
|
||||||
animatedBottomStyles,
|
|
||||||
]}
|
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
|
||||||
className={`flex flex-col p-4 `}
|
|
||||||
>
|
|
||||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
|
||||||
<Text className="font-bold">{item?.Name}</Text>
|
|
||||||
{item?.Type === "Episode" && (
|
|
||||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
|
||||||
)}
|
|
||||||
{item?.Type === "Movie" && (
|
|
||||||
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
|
|
||||||
)}
|
|
||||||
{item?.Type === "Audio" && (
|
|
||||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-row items-center space-x-4">
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
opacity: !previousItem ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
onPress={goToPreviousItem}
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={handleSkipBackward}>
|
|
||||||
<Ionicons
|
|
||||||
name="refresh-outline"
|
|
||||||
size={26}
|
|
||||||
color="white"
|
|
||||||
style={{
|
|
||||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
togglePlay(progress.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={30}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={handleSkipForward}>
|
|
||||||
<Ionicons name="refresh-outline" size={26} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
opacity: !nextItem ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
onPress={goToNextItem}
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<View className={`flex flex-col w-full shrink`}>
|
|
||||||
<Slider
|
|
||||||
theme={{
|
|
||||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
|
||||||
minimumTrackTintColor: "#fff",
|
|
||||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
|
||||||
bubbleBackgroundColor: "#fff",
|
|
||||||
bubbleTextColor: "#000",
|
|
||||||
heartbeatColor: "#999",
|
|
||||||
}}
|
|
||||||
cache={cacheProgress}
|
|
||||||
onSlidingStart={handleSliderStart}
|
|
||||||
onSlidingComplete={handleSliderComplete}
|
|
||||||
onValueChange={handleSliderChange}
|
|
||||||
containerStyle={{
|
|
||||||
borderRadius: 100,
|
|
||||||
}}
|
|
||||||
renderBubble={() => {
|
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const { x, y, url } = trickPlayUrl;
|
|
||||||
|
|
||||||
const tileWidth = 150;
|
|
||||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight,
|
|
||||||
marginLeft: -tileWidth / 4,
|
|
||||||
marginTop: -tileHeight / 4 - 60,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
className=" bg-neutral-800 overflow-hidden"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
style={{
|
|
||||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
|
||||||
height:
|
|
||||||
(150 / trickplayInfo.aspectRatio!) *
|
|
||||||
trickplayInfo?.data.TileHeight!,
|
|
||||||
transform: [
|
|
||||||
{ translateX: -x * tileWidth },
|
|
||||||
{ translateY: -y * tileHeight },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
source={{ uri: url }}
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
sliderHeight={10}
|
|
||||||
thumbWidth={0}
|
|
||||||
progress={progress}
|
|
||||||
minimumValue={min}
|
|
||||||
maximumValue={max}
|
|
||||||
/>
|
|
||||||
<View className="flex flex-row items-center justify-between mt-0.5">
|
|
||||||
<Text className="text-[12px] text-neutral-400">
|
|
||||||
{formatTimeString(currentTime)}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[12px] text-neutral-400">
|
|
||||||
-{formatTimeString(remainingTime)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
114
components/video-player/controls/AudioSlider.tsx
Normal file
114
components/video-player/controls/AudioSlider.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import { VolumeManager } from "react-native-volume-manager";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
interface AudioSliderProps {
|
||||||
|
setVisibility: (show: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||||
|
const volume = useSharedValue<number>(50); // Explicitly type as number
|
||||||
|
const min = useSharedValue<number>(0); // Explicitly type as number
|
||||||
|
const max = useSharedValue<number>(100); // Explicitly type as number
|
||||||
|
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null); // Use a ref to store the timeout ID
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInitialVolume = async () => {
|
||||||
|
try {
|
||||||
|
const { volume: initialVolume } = await VolumeManager.getVolume();
|
||||||
|
console.log("initialVolume", initialVolume);
|
||||||
|
volume.value = initialVolume * 100;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching initial volume:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchInitialVolume();
|
||||||
|
|
||||||
|
// Disable the native volume UI when the component mounts
|
||||||
|
VolumeManager.showNativeVolumeUI({ enabled: false });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Re-enable the native volume UI when the component unmounts
|
||||||
|
VolumeManager.showNativeVolumeUI({ enabled: true });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValueChange = async (value: number) => {
|
||||||
|
volume.value = value;
|
||||||
|
console.log("volume through slider", value);
|
||||||
|
await VolumeManager.setVolume(value / 100);
|
||||||
|
|
||||||
|
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
||||||
|
VolumeManager.showNativeVolumeUI({ enabled: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const volumeListener = VolumeManager.addVolumeListener((result) => {
|
||||||
|
console.log("Volume through device", result.volume);
|
||||||
|
volume.value = result.volume * 100;
|
||||||
|
setVisibility(true);
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a new timeout to hide the visibility after 2 seconds
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setVisibility(false);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
volumeListener.remove();
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<Slider
|
||||||
|
progress={volume}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
thumbWidth={0}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 50,
|
||||||
|
}}
|
||||||
|
theme={{
|
||||||
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
|
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||||
|
bubbleTextColor: "transparent", // Hide the value text
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Ionicons
|
||||||
|
name="volume-high"
|
||||||
|
size={20}
|
||||||
|
color="#FDFDFD"
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
sliderContainer: {
|
||||||
|
width: 150,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AudioSlider;
|
||||||
68
components/video-player/controls/BrightnessSlider.tsx
Normal file
68
components/video-player/controls/BrightnessSlider.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import * as Brightness from "expo-brightness";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
|
||||||
|
|
||||||
|
const BrightnessSlider = () => {
|
||||||
|
const brightness = useSharedValue(50);
|
||||||
|
const min = useSharedValue(0);
|
||||||
|
const max = useSharedValue(100);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInitialBrightness = async () => {
|
||||||
|
const initialBrightness = await Brightness.getBrightnessAsync();
|
||||||
|
console.log("initialBrightness", initialBrightness);
|
||||||
|
brightness.value = initialBrightness * 100;
|
||||||
|
};
|
||||||
|
fetchInitialBrightness();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValueChange = async (value: number) => {
|
||||||
|
brightness.value = value;
|
||||||
|
await Brightness.setBrightnessAsync(value / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<Slider
|
||||||
|
progress={brightness}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
thumbWidth={0}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 50,
|
||||||
|
}}
|
||||||
|
theme={{
|
||||||
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
|
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||||
|
bubbleTextColor: "transparent", // Hide the value text
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Ionicons
|
||||||
|
name="sunny"
|
||||||
|
size={20}
|
||||||
|
color="#FDFDFD"
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
sliderContainer: {
|
||||||
|
width: 150,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BrightnessSlider;
|
||||||
841
components/video-player/controls/Controls.tsx
Normal file
841
components/video-player/controls/Controls.tsx
Normal file
@@ -0,0 +1,841 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import {
|
||||||
|
TrackInfo,
|
||||||
|
VlcPlayerViewRef,
|
||||||
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
getDefaultPlaySettings,
|
||||||
|
previousIndexes,
|
||||||
|
} from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import {
|
||||||
|
formatTimeString,
|
||||||
|
msToTicks,
|
||||||
|
secondsToMs,
|
||||||
|
ticksToMs,
|
||||||
|
ticksToSeconds,
|
||||||
|
} from "@/utils/time";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import {
|
||||||
|
runOnJS,
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useSharedValue,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import {
|
||||||
|
SafeAreaView,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from "react-native-safe-area-context";
|
||||||
|
import { VideoRef } from "react-native-video";
|
||||||
|
import AudioSlider from "./AudioSlider";
|
||||||
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
|
import { ControlProvider } from "./contexts/ControlContext";
|
||||||
|
import { VideoProvider } from "./contexts/VideoContext";
|
||||||
|
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
||||||
|
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
||||||
|
import { EpisodeList } from "./EpisodeList";
|
||||||
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
|
import SkipButton from "./SkipButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto;
|
||||||
|
videoRef: React.MutableRefObject<VlcPlayerViewRef | VideoRef | null>;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isSeeking: SharedValue<boolean>;
|
||||||
|
cacheProgress: SharedValue<number>;
|
||||||
|
progress: SharedValue<number>;
|
||||||
|
isBuffering: boolean;
|
||||||
|
showControls: boolean;
|
||||||
|
ignoreSafeAreas?: boolean;
|
||||||
|
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
enableTrickplay?: boolean;
|
||||||
|
togglePlay: () => void;
|
||||||
|
setShowControls: (shown: boolean) => void;
|
||||||
|
offline?: boolean;
|
||||||
|
isVideoLoaded?: boolean;
|
||||||
|
mediaSource?: MediaSourceInfo | null;
|
||||||
|
seek: (ticks: number) => void;
|
||||||
|
play: (() => Promise<void>) | (() => void);
|
||||||
|
pause: () => void;
|
||||||
|
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||||
|
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||||
|
setSubtitleURL?: (url: string, customName: string) => void;
|
||||||
|
setSubtitleTrack?: (index: number) => void;
|
||||||
|
setAudioTrack?: (index: number) => void;
|
||||||
|
stop: (() => Promise<void>) | (() => void);
|
||||||
|
isVlc?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Controls: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
togglePlay,
|
||||||
|
isPlaying,
|
||||||
|
isSeeking,
|
||||||
|
progress,
|
||||||
|
isBuffering,
|
||||||
|
cacheProgress,
|
||||||
|
showControls,
|
||||||
|
setShowControls,
|
||||||
|
ignoreSafeAreas,
|
||||||
|
setIgnoreSafeAreas,
|
||||||
|
mediaSource,
|
||||||
|
isVideoLoaded,
|
||||||
|
getAudioTracks,
|
||||||
|
getSubtitleTracks,
|
||||||
|
setSubtitleURL,
|
||||||
|
setSubtitleTrack,
|
||||||
|
setAudioTrack,
|
||||||
|
stop,
|
||||||
|
offline = false,
|
||||||
|
enableTrickplay = true,
|
||||||
|
isVlc = false,
|
||||||
|
}) => {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||||
|
const {
|
||||||
|
trickPlayUrl,
|
||||||
|
calculateTrickplayUrl,
|
||||||
|
trickplayInfo,
|
||||||
|
prefetchAllTrickplayImages,
|
||||||
|
} = useTrickplay(item, !offline && enableTrickplay);
|
||||||
|
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [remainingTime, setRemainingTime] = useState(Infinity);
|
||||||
|
|
||||||
|
const min = useSharedValue(0);
|
||||||
|
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||||
|
|
||||||
|
const wasPlayingRef = useRef(false);
|
||||||
|
const lastProgressRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||||
|
bitrateValue: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
|
offline ? undefined : item.Id,
|
||||||
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
|
isVlc
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||||
|
offline ? undefined : item.Id,
|
||||||
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
|
isVlc
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToPreviousItem = useCallback(() => {
|
||||||
|
if (!previousItem || !settings) return;
|
||||||
|
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
const previousIndexes: previousIndexes = {
|
||||||
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
|
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
mediaSource: newMediaSource,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
|
} = getDefaultPlaySettings(
|
||||||
|
previousItem,
|
||||||
|
settings,
|
||||||
|
previousIndexes,
|
||||||
|
mediaSource ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
||||||
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
|
bitrateValue: bitrateValue.toString(),
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
if (!bitrateValue) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
|
}, [previousItem, settings, subtitleIndex, audioIndex]);
|
||||||
|
|
||||||
|
const goToNextItem = useCallback(() => {
|
||||||
|
if (!nextItem || !settings) return;
|
||||||
|
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
const previousIndexes: previousIndexes = {
|
||||||
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
|
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
mediaSource: newMediaSource,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
|
} = getDefaultPlaySettings(
|
||||||
|
nextItem,
|
||||||
|
settings,
|
||||||
|
previousIndexes,
|
||||||
|
mediaSource ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
||||||
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
|
bitrateValue: bitrateValue.toString(),
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
if (!bitrateValue) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
|
}, [nextItem, settings, subtitleIndex, audioIndex]);
|
||||||
|
|
||||||
|
const updateTimes = useCallback(
|
||||||
|
(currentProgress: number, maxValue: number) => {
|
||||||
|
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
||||||
|
const remaining = isVlc
|
||||||
|
? maxValue - currentProgress
|
||||||
|
: ticksToSeconds(maxValue - currentProgress);
|
||||||
|
|
||||||
|
console.log("remaining: ", remaining);
|
||||||
|
|
||||||
|
setCurrentTime(current);
|
||||||
|
setRemainingTime(remaining);
|
||||||
|
},
|
||||||
|
[goToNextItem, isVlc]
|
||||||
|
);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
progress: progress.value,
|
||||||
|
max: max.value,
|
||||||
|
isSeeking: isSeeking.value,
|
||||||
|
}),
|
||||||
|
(result) => {
|
||||||
|
if (result.isSeeking === false) {
|
||||||
|
runOnJS(updateTimes)(result.progress, result.max);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateTimes]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
progress.value = isVlc
|
||||||
|
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
||||||
|
: item?.UserData?.PlaybackPositionTicks || 0;
|
||||||
|
max.value = isVlc
|
||||||
|
? ticksToMs(item.RunTimeTicks || 0)
|
||||||
|
: item.RunTimeTicks || 0;
|
||||||
|
}
|
||||||
|
}, [item, isVlc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchAllTrickplayImages();
|
||||||
|
}, []);
|
||||||
|
const toggleControls = () => {
|
||||||
|
if (showControls) {
|
||||||
|
setShowAudioSlider(false);
|
||||||
|
setShowControls(false);
|
||||||
|
} else {
|
||||||
|
setShowControls(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderStart = useCallback(() => {
|
||||||
|
if (showControls === false) return;
|
||||||
|
|
||||||
|
setIsSliding(true);
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
lastProgressRef.current = progress.value;
|
||||||
|
|
||||||
|
pause();
|
||||||
|
isSeeking.value = true;
|
||||||
|
}, [showControls, isPlaying]);
|
||||||
|
|
||||||
|
const [isSliding, setIsSliding] = useState(false);
|
||||||
|
const handleSliderComplete = useCallback(
|
||||||
|
async (value: number) => {
|
||||||
|
isSeeking.value = false;
|
||||||
|
progress.value = value;
|
||||||
|
setIsSliding(false);
|
||||||
|
|
||||||
|
await seek(
|
||||||
|
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
|
||||||
|
);
|
||||||
|
if (wasPlayingRef.current === true) play();
|
||||||
|
},
|
||||||
|
[isVlc]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||||
|
const handleSliderChange = useCallback(
|
||||||
|
debounce((value: number) => {
|
||||||
|
const progressInTicks = isVlc ? msToTicks(value) : value;
|
||||||
|
calculateTrickplayUrl(progressInTicks);
|
||||||
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
|
const seconds = progressInSeconds % 60;
|
||||||
|
setTime({ hours, minutes, seconds });
|
||||||
|
}, 3),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSkipBackward = useCallback(async () => {
|
||||||
|
if (!settings?.rewindSkipTime) return;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = isVlc
|
||||||
|
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
||||||
|
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
||||||
|
await seek(newTime);
|
||||||
|
if (wasPlayingRef.current === true) play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
|
}
|
||||||
|
}, [settings, isPlaying, isVlc]);
|
||||||
|
|
||||||
|
const handleSkipForward = useCallback(async () => {
|
||||||
|
if (!settings?.forwardSkipTime) return;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
console.log(curr);
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = isVlc
|
||||||
|
? curr + secondsToMs(settings.forwardSkipTime)
|
||||||
|
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
||||||
|
await seek(Math.max(0, newTime));
|
||||||
|
if (wasPlayingRef.current === true) play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
|
}
|
||||||
|
}, [settings, isPlaying, isVlc]);
|
||||||
|
|
||||||
|
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||||
|
setIgnoreSafeAreas((prev) => !prev);
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const memoizedRenderBubble = useCallback(() => {
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
const tileWidth = 150;
|
||||||
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
|
||||||
|
console.log("time, ", time);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: -57,
|
||||||
|
bottom: 15,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 5,
|
||||||
|
width: tileWidth * 1.5,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
alignSelf: "center",
|
||||||
|
transform: [{ scale: 1.4 }],
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
className="bg-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
style={{
|
||||||
|
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||||
|
height:
|
||||||
|
(150 / trickplayInfo.aspectRatio!) *
|
||||||
|
trickplayInfo?.data.TileHeight!,
|
||||||
|
transform: [
|
||||||
|
{ translateX: -x * tileWidth },
|
||||||
|
{ translateY: -y * tileHeight },
|
||||||
|
],
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||||
|
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||||
|
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [trickPlayUrl, trickplayInfo, time]);
|
||||||
|
|
||||||
|
const [EpisodeView, setEpisodeView] = useState(false);
|
||||||
|
|
||||||
|
const switchOnEpisodeMode = () => {
|
||||||
|
setEpisodeView(true);
|
||||||
|
if (isPlaying) togglePlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToItem = useCallback(
|
||||||
|
async (itemId: string) => {
|
||||||
|
try {
|
||||||
|
const gotoItem = await getItemById(api, itemId);
|
||||||
|
if (!settings || !gotoItem) return;
|
||||||
|
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
const previousIndexes: previousIndexes = {
|
||||||
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
|
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
mediaSource: newMediaSource,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
|
} = getDefaultPlaySettings(
|
||||||
|
gotoItem,
|
||||||
|
settings,
|
||||||
|
previousIndexes,
|
||||||
|
mediaSource ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
|
||||||
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
|
bitrateValue: bitrateValue.toString(),
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
if (!bitrateValue) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in gotoEpisode:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settings, subtitleIndex, audioIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Used when user changes audio through audio button on device.
|
||||||
|
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlProvider
|
||||||
|
item={item}
|
||||||
|
mediaSource={mediaSource}
|
||||||
|
isVideoLoaded={isVideoLoaded}
|
||||||
|
>
|
||||||
|
{EpisodeView ? (
|
||||||
|
<EpisodeList
|
||||||
|
item={item}
|
||||||
|
close={() => setEpisodeView(false)}
|
||||||
|
goToItem={goToItem}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<VideoProvider
|
||||||
|
getAudioTracks={getAudioTracks}
|
||||||
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
|
setAudioTrack={setAudioTrack}
|
||||||
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
|
setSubtitleURL={setSubtitleURL}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top,
|
||||||
|
left: insets.left,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{!mediaSource?.TranscodingUrl ? (
|
||||||
|
<DropdownViewDirect showControls={showControls} />
|
||||||
|
) : (
|
||||||
|
<DropdownViewTranscoding showControls={showControls} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</VideoProvider>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPressIn={() => {
|
||||||
|
toggleControls();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: Dimensions.get("window").width,
|
||||||
|
height: Dimensions.get("window").height,
|
||||||
|
}}
|
||||||
|
></Pressable>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top,
|
||||||
|
right: insets.right,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
|
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||||
|
>
|
||||||
|
{item?.Type === "Episode" && !offline && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
switchOnEpisodeMode();
|
||||||
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="list" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{previousItem && !offline && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={goToPreviousItem}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextItem && !offline && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={goToNextItem}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mediaSource?.TranscodingUrl && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={toggleIgnoreSafeAreas}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={async () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%", // Center vertically
|
||||||
|
left: insets.left,
|
||||||
|
right: insets.right,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
|
||||||
|
paddingHorizontal: "28%", // Add some padding to the left and right
|
||||||
|
}}
|
||||||
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
alignItems: "center",
|
||||||
|
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
|
||||||
|
left: 0,
|
||||||
|
bottom: 30,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrightnessSlider />
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="refresh-outline"
|
||||||
|
size={50}
|
||||||
|
color="white"
|
||||||
|
style={{
|
||||||
|
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.rewindSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
togglePlay();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isBuffering ? (
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={50}
|
||||||
|
color="white"
|
||||||
|
style={{
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Loader size={"large"} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh-outline" size={50} color="white" />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.forwardSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
alignItems: "center",
|
||||||
|
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
|
||||||
|
bottom: 30,
|
||||||
|
right: 0,
|
||||||
|
opacity: showAudioSlider || showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AudioSlider setVisibility={setShowAudioSlider} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
right: insets.right,
|
||||||
|
left: insets.left,
|
||||||
|
bottom: insets.bottom,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className={`flex flex-col p-4`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="shrink flex flex-col justify-center h-full mb-2"
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
alignSelf: "flex-end", // Shrink height based on content
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
|
>
|
||||||
|
<Text className="font-bold">{item?.Name}</Text>
|
||||||
|
{item?.Type === "Episode" && (
|
||||||
|
<Text className="opacity-50">{item.SeriesName}</Text>
|
||||||
|
)}
|
||||||
|
{item?.Type === "Movie" && (
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{item?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{item?.Type === "Audio" && (
|
||||||
|
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row space-x-2">
|
||||||
|
<SkipButton
|
||||||
|
showButton={showSkipButton}
|
||||||
|
onPress={skipIntro}
|
||||||
|
buttonText="Skip Intro"
|
||||||
|
/>
|
||||||
|
<SkipButton
|
||||||
|
showButton={showSkipCreditButton}
|
||||||
|
onPress={skipCredit}
|
||||||
|
buttonText="Skip Credits"
|
||||||
|
/>
|
||||||
|
<NextEpisodeCountDownButton
|
||||||
|
show={
|
||||||
|
!nextItem
|
||||||
|
? false
|
||||||
|
: isVlc
|
||||||
|
? remainingTime < 10000
|
||||||
|
: remainingTime < 10
|
||||||
|
}
|
||||||
|
onFinish={goToNextItem}
|
||||||
|
onPress={goToNextItem}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
|
||||||
|
style={{
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
|
>
|
||||||
|
<View className={`flex flex-col w-full shrink`}>
|
||||||
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
|
bubbleBackgroundColor: "#fff",
|
||||||
|
bubbleTextColor: "#666",
|
||||||
|
heartbeatColor: "#999",
|
||||||
|
}}
|
||||||
|
renderThumb={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
left: -2,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
cache={cacheProgress}
|
||||||
|
onSlidingStart={handleSliderStart}
|
||||||
|
onSlidingComplete={handleSliderComplete}
|
||||||
|
onValueChange={handleSliderChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 100,
|
||||||
|
}}
|
||||||
|
renderBubble={() => isSliding && memoizedRenderBubble()}
|
||||||
|
sliderHeight={10}
|
||||||
|
thumbWidth={0}
|
||||||
|
progress={progress}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
/>
|
||||||
|
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ControlProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
254
components/video-player/controls/EpisodeList.tsx
Normal file
254
components/video-player/controls/EpisodeList.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo, useState, useRef } from "react";
|
||||||
|
import { View, TouchableOpacity } from "react-native";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import {
|
||||||
|
HorizontalScroll,
|
||||||
|
HorizontalScrollRef,
|
||||||
|
} from "@/components/common/HorrizontalScroll";
|
||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
close: () => void;
|
||||||
|
goToItem: (itemId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
|
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets(); // Get safe area insets
|
||||||
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
|
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||||
|
const scrollToIndex = (index: number) => {
|
||||||
|
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the initial season index
|
||||||
|
useEffect(() => {
|
||||||
|
if (item.SeriesId) {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.SeriesId ?? ""]: item.ParentIndexNumber ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
||||||
|
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
|
||||||
|
|
||||||
|
// This effect fetches the series item data/
|
||||||
|
useEffect(() => {
|
||||||
|
if (item.SeriesId) {
|
||||||
|
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
|
||||||
|
(res) => {
|
||||||
|
setSeriesItem(res);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [item.SeriesId]);
|
||||||
|
|
||||||
|
const { data: seasons } = useQuery({
|
||||||
|
queryKey: ["seasons", item.SeriesId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item.SeriesId) return [];
|
||||||
|
const response = await api.axiosInstance.get(
|
||||||
|
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: item.SeriesId,
|
||||||
|
Fields:
|
||||||
|
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data.Items;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!item.SeasonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSeasonId: string | null = useMemo(
|
||||||
|
() =>
|
||||||
|
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||||
|
[seasons, seasonIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: episodes, isFetching } = useQuery({
|
||||||
|
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId || "",
|
||||||
|
userId: user.Id,
|
||||||
|
seasonId: selectedSeasonId || undefined,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.Items;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!selectedSeasonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item?.Type === "Episode" && item.Id) {
|
||||||
|
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||||
|
if (index !== undefined && index !== -1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToIndex(index);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [episodes, item]);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
useEffect(() => {
|
||||||
|
for (let e of episodes || []) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["item", e.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!e.Id) return;
|
||||||
|
const res = await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: e.Id,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 5 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
|
// Scroll to the current item when episodes are fetched
|
||||||
|
useEffect(() => {
|
||||||
|
if (episodes && scrollViewRef.current) {
|
||||||
|
const currentItemIndex = episodes.findIndex((e) => e.Id === item.Id);
|
||||||
|
if (currentItemIndex !== -1) {
|
||||||
|
scrollViewRef.current.scrollToIndex(currentItemIndex, 16); // Adjust the scroll position based on item width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [episodes, item.Id]);
|
||||||
|
|
||||||
|
if (!episodes) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
backgroundColor: "black",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
||||||
|
>
|
||||||
|
{seriesItem && (
|
||||||
|
<SeasonDropdown
|
||||||
|
item={seriesItem}
|
||||||
|
seasons={seasons}
|
||||||
|
state={seasonIndexState}
|
||||||
|
onSelect={(season) => {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.SeriesId ?? ""]: season.IndexNumber,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={async () => {
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<HorizontalScroll
|
||||||
|
ref={scrollViewRef}
|
||||||
|
data={episodes}
|
||||||
|
extraData={item}
|
||||||
|
renderItem={(_item, idx) => (
|
||||||
|
<View
|
||||||
|
key={_item.Id}
|
||||||
|
style={{}}
|
||||||
|
className={`flex flex-col w-44 ${
|
||||||
|
item.Id !== _item.Id ? "opacity-75" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
goToItem(_item.Id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContinueWatchingPoster
|
||||||
|
item={_item}
|
||||||
|
useEpisodePoster
|
||||||
|
showPlayButton={_item.Id !== item.Id}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View className="shrink">
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{
|
||||||
|
lineHeight: 18, // Adjust this value based on your text size
|
||||||
|
height: 36, // lineHeight * 2 for consistent two-line space
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{_item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className="text-xs text-neutral-475">
|
||||||
|
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="self-start mt-2">
|
||||||
|
<DownloadSingleItem item={_item} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={5}
|
||||||
|
className="text-xs text-neutral-500 shrink"
|
||||||
|
>
|
||||||
|
{_item.Overview}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
Easing,
|
||||||
|
runOnJS,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
|
||||||
|
onFinish?: () => void;
|
||||||
|
onPress?: () => void;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||||
|
onFinish,
|
||||||
|
onPress,
|
||||||
|
show,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
progress.value = 0;
|
||||||
|
progress.value = withTiming(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
duration: 10000, // 10 seconds
|
||||||
|
easing: Easing.linear,
|
||||||
|
},
|
||||||
|
(finished) => {
|
||||||
|
if (finished && onFinish) {
|
||||||
|
console.log("finish");
|
||||||
|
runOnJS(onFinish)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [show, onFinish]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: `${progress.value * 100}%`,
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (onPress) {
|
||||||
|
onPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
|
||||||
|
{...props}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
<Animated.View style={animatedStyle} />
|
||||||
|
<View className="px-3 py-3">
|
||||||
|
<Text className="text-center font-bold">Next Episode</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NextEpisodeCountDownButton;
|
||||||
28
components/video-player/controls/SkipButton.tsx
Normal file
28
components/video-player/controls/SkipButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, TouchableOpacity, Text, ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface SkipButtonProps extends ViewProps {
|
||||||
|
onPress: () => void;
|
||||||
|
showButton: boolean;
|
||||||
|
buttonText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkipButton: React.FC<SkipButtonProps> = ({
|
||||||
|
onPress,
|
||||||
|
showButton,
|
||||||
|
buttonText,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
className="bg-black/60 rounded-md px-3 py-3 border border-neutral-900"
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold">{buttonText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SkipButton;
|
||||||
144
components/video-player/controls/SliderScrubbter.tsx
Normal file
144
components/video-player/controls/SliderScrubbter.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useTrickplay } from '@/hooks/useTrickplay';
|
||||||
|
import { formatTimeString, msToTicks, ticksToSeconds } from '@/utils/time';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import { SharedValue, useSharedValue } from 'react-native-reanimated';
|
||||||
|
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
|
||||||
|
interface SliderScrubberProps {
|
||||||
|
cacheProgress: SharedValue<number>;
|
||||||
|
handleSliderStart: () => void;
|
||||||
|
handleSliderComplete: (value: number) => void;
|
||||||
|
progress: SharedValue<number>;
|
||||||
|
min: SharedValue<number>;
|
||||||
|
max: SharedValue<number>;
|
||||||
|
currentTime: number;
|
||||||
|
remainingTime: number;
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SliderScrubber: React.FC<SliderScrubberProps> = ({
|
||||||
|
cacheProgress,
|
||||||
|
handleSliderStart,
|
||||||
|
handleSliderComplete,
|
||||||
|
progress,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
currentTime,
|
||||||
|
remainingTime,
|
||||||
|
item,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
|
||||||
|
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||||
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||||
|
item,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSliderChange = (value: number) => {
|
||||||
|
const progressInTicks = msToTicks(value);
|
||||||
|
calculateTrickplayUrl(progressInTicks);
|
||||||
|
|
||||||
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||||
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
|
const seconds = progressInSeconds % 60;
|
||||||
|
setTime({ hours, minutes, seconds });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={`flex flex-col w-full shrink`}>
|
||||||
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
|
bubbleBackgroundColor: "#fff",
|
||||||
|
bubbleTextColor: "#000",
|
||||||
|
heartbeatColor: "#999",
|
||||||
|
}}
|
||||||
|
cache={cacheProgress}
|
||||||
|
onSlidingStart={handleSliderStart}
|
||||||
|
onSlidingComplete={handleSliderComplete}
|
||||||
|
onValueChange={handleSliderChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 100,
|
||||||
|
}}
|
||||||
|
renderBubble={() => {
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
|
||||||
|
const tileWidth = 150;
|
||||||
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
marginLeft: -tileWidth / 4,
|
||||||
|
marginTop: -tileHeight / 4 - 60,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
className=" bg-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
style={{
|
||||||
|
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||||
|
height:
|
||||||
|
(150 / trickplayInfo.aspectRatio!) *
|
||||||
|
trickplayInfo?.data.TileHeight!,
|
||||||
|
transform: [
|
||||||
|
{ translateX: -x * tileWidth },
|
||||||
|
{ translateY: -y * tileHeight },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 5,
|
||||||
|
left: 5,
|
||||||
|
color: "white",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
padding: 5,
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||||
|
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||||
|
}:${
|
||||||
|
time.seconds < 10 ? `0${time.seconds}` : time.seconds
|
||||||
|
}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sliderHeight={10}
|
||||||
|
thumbWidth={0}
|
||||||
|
progress={progress}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
/>
|
||||||
|
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
{formatTimeString(currentTime, "ms")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SliderScrubber;
|
||||||
44
components/video-player/controls/contexts/ControlContext.tsx
Normal file
44
components/video-player/controls/contexts/ControlContext.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { TrackInfo } from "@/modules/vlc-player";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React, { createContext, useContext, useState, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ControlContextProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined;
|
||||||
|
isVideoLoaded: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlContext = createContext<ControlContextProps | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ControlProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
item: BaseItemDto;
|
||||||
|
mediaSource: MediaSourceInfo | null | undefined;
|
||||||
|
isVideoLoaded: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ControlProvider: React.FC<ControlProviderProps> = ({
|
||||||
|
children,
|
||||||
|
item,
|
||||||
|
mediaSource,
|
||||||
|
isVideoLoaded,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
||||||
|
{children}
|
||||||
|
</ControlContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useControlContext = () => {
|
||||||
|
const context = useContext(ControlContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useControlContext must be used within a ControlProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
98
components/video-player/controls/contexts/VideoContext.tsx
Normal file
98
components/video-player/controls/contexts/VideoContext.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { TrackInfo } from "@/modules/vlc-player";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
import { useControlContext } from "./ControlContext";
|
||||||
|
|
||||||
|
interface VideoContextProps {
|
||||||
|
audioTracks: TrackInfo[] | null;
|
||||||
|
subtitleTracks: TrackInfo[] | null;
|
||||||
|
setAudioTrack: ((index: number) => void) | undefined;
|
||||||
|
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||||
|
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
||||||
|
|
||||||
|
interface VideoProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
getAudioTracks:
|
||||||
|
| (() => Promise<TrackInfo[] | null>)
|
||||||
|
| (() => TrackInfo[])
|
||||||
|
| undefined;
|
||||||
|
getSubtitleTracks:
|
||||||
|
| (() => Promise<TrackInfo[] | null>)
|
||||||
|
| (() => TrackInfo[])
|
||||||
|
| undefined;
|
||||||
|
setAudioTrack: ((index: number) => void) | undefined;
|
||||||
|
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||||
|
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||||
|
children,
|
||||||
|
getSubtitleTracks,
|
||||||
|
getAudioTracks,
|
||||||
|
setSubtitleTrack,
|
||||||
|
setSubtitleURL,
|
||||||
|
setAudioTrack,
|
||||||
|
}) => {
|
||||||
|
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
||||||
|
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const ControlContext = useControlContext();
|
||||||
|
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTracks = async () => {
|
||||||
|
if (
|
||||||
|
getSubtitleTracks &&
|
||||||
|
(subtitleTracks === null || subtitleTracks.length === 0)
|
||||||
|
) {
|
||||||
|
const subtitles = await getSubtitleTracks();
|
||||||
|
console.log("Getting embeded subtitles...", subtitles);
|
||||||
|
setSubtitleTracks(subtitles);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
getAudioTracks &&
|
||||||
|
(audioTracks === null || audioTracks.length === 0)
|
||||||
|
) {
|
||||||
|
const audio = await getAudioTracks();
|
||||||
|
setAudioTracks(audio);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTracks();
|
||||||
|
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VideoContext.Provider
|
||||||
|
value={{
|
||||||
|
audioTracks,
|
||||||
|
subtitleTracks,
|
||||||
|
setSubtitleTrack,
|
||||||
|
setSubtitleURL,
|
||||||
|
setAudioTrack,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</VideoContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useVideoContext = () => {
|
||||||
|
const context = useContext(VideoContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useVideoContext must be used within a VideoProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
165
components/video-player/controls/dropdown/DropdownViewDirect.tsx
Normal file
165
components/video-player/controls/dropdown/DropdownViewDirect.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { View, TouchableOpacity } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
|
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
interface DropdownViewDirectProps {
|
||||||
|
showControls: boolean;
|
||||||
|
offline?: boolean; // used to disable external subs for downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||||
|
showControls,
|
||||||
|
offline = false,
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const ControlContext = useControlContext();
|
||||||
|
const mediaSource = ControlContext?.mediaSource;
|
||||||
|
const item = ControlContext?.item;
|
||||||
|
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||||
|
|
||||||
|
const videoContext = useVideoContext();
|
||||||
|
const {
|
||||||
|
subtitleTracks,
|
||||||
|
audioTracks,
|
||||||
|
setSubtitleURL,
|
||||||
|
setSubtitleTrack,
|
||||||
|
setAudioTrack,
|
||||||
|
} = videoContext;
|
||||||
|
|
||||||
|
const allSubtitleTracksForDirectPlay = useMemo(() => {
|
||||||
|
if (mediaSource?.TranscodingUrl) return null;
|
||||||
|
const embeddedSubs =
|
||||||
|
subtitleTracks
|
||||||
|
?.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
index: s.index,
|
||||||
|
deliveryUrl: undefined,
|
||||||
|
}))
|
||||||
|
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
||||||
|
|
||||||
|
const externalSubs =
|
||||||
|
mediaSource?.MediaStreams?.filter(
|
||||||
|
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
||||||
|
).map((s) => ({
|
||||||
|
name: s.DisplayTitle! + " [External]",
|
||||||
|
index: s.Index!,
|
||||||
|
deliveryUrl: s.DeliveryUrl,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
// Combine embedded subs with external subs only if not offline
|
||||||
|
if (!offline) {
|
||||||
|
return [...embeddedSubs, ...externalSubs] as (
|
||||||
|
| EmbeddedSubtitle
|
||||||
|
| ExternalSubtitle
|
||||||
|
)[];
|
||||||
|
}
|
||||||
|
return embeddedSubs as EmbeddedSubtitle[];
|
||||||
|
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
|
||||||
|
|
||||||
|
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||||
|
itemId: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
||||||
|
Subtitle
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key={`subtitle-item-${idx}`}
|
||||||
|
value={subtitleIndex === sub.index.toString()}
|
||||||
|
onValueChange={() => {
|
||||||
|
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||||
|
setSubtitleURL &&
|
||||||
|
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Set external subtitle: ",
|
||||||
|
api?.basePath + sub.deliveryUrl
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("Set sub index: ", sub.index);
|
||||||
|
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||||
|
}
|
||||||
|
router.setParams({
|
||||||
|
subtitleIndex: sub.index.toString(),
|
||||||
|
});
|
||||||
|
console.log("Subtitle: ", sub);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||||
|
{sub.name}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||||
|
Audio
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
{audioTracks?.map((track, idx: number) => (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key={`audio-item-${idx}`}
|
||||||
|
value={audioIndex === track.index.toString()}
|
||||||
|
onValueChange={() => {
|
||||||
|
setAudioTrack && setAudioTrack(track.index);
|
||||||
|
router.setParams({
|
||||||
|
audioIndex: track.index.toString(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||||
|
{track.name}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownViewDirect;
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { View, TouchableOpacity } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
|
import { TranscodedSubtitle } from "../types";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
|
|
||||||
|
interface DropdownViewProps {
|
||||||
|
showControls: boolean;
|
||||||
|
offline?: boolean; // used to disable external subs for downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const ControlContext = useControlContext();
|
||||||
|
const mediaSource = ControlContext?.mediaSource;
|
||||||
|
const item = ControlContext?.item;
|
||||||
|
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||||
|
|
||||||
|
const videoContext = useVideoContext();
|
||||||
|
const { subtitleTracks, setSubtitleTrack } = videoContext;
|
||||||
|
|
||||||
|
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
|
||||||
|
itemId: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
||||||
|
|
||||||
|
const isOnTextSubtitle = useMemo(() => {
|
||||||
|
const res = Boolean(
|
||||||
|
mediaSource?.MediaStreams?.find(
|
||||||
|
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
||||||
|
) || subtitleIndex === "-1"
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allSubs =
|
||||||
|
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
||||||
|
|
||||||
|
const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
|
||||||
|
|
||||||
|
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
||||||
|
const disableSubtitle = {
|
||||||
|
name: "Disable",
|
||||||
|
index: -1,
|
||||||
|
IsTextSubtitleStream: true,
|
||||||
|
} as TranscodedSubtitle;
|
||||||
|
if (isOnTextSubtitle) {
|
||||||
|
const textSubtitles =
|
||||||
|
subtitleTracks?.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
index: s.index,
|
||||||
|
IsTextSubtitleStream: true,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
||||||
|
|
||||||
|
console.log("sortedSubtitles", sortedSubtitles);
|
||||||
|
return [disableSubtitle, ...sortedSubtitles];
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
|
||||||
|
name: x.DisplayTitle!,
|
||||||
|
index: x.Index!,
|
||||||
|
IsTextSubtitleStream: x.IsTextSubtitleStream!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [disableSubtitle, ...transcodedSubtitle];
|
||||||
|
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||||
|
|
||||||
|
const changeToImageBasedSub = useCallback(
|
||||||
|
(subtitleIndex: number) => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
|
bitrateValue: bitrateValue,
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
|
},
|
||||||
|
[mediaSource]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Audio tracks for transcoding streams.
|
||||||
|
const allAudio =
|
||||||
|
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
|
||||||
|
name: x.DisplayTitle!,
|
||||||
|
index: x.Index!,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const ChangeTranscodingAudio = useCallback(
|
||||||
|
(audioIndex: number) => {
|
||||||
|
console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex);
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
|
bitrateValue: bitrateValue,
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
|
},
|
||||||
|
[mediaSource, subtitleIndex, audioIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 1000,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
className="p-4"
|
||||||
|
>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
||||||
|
Subtitle
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
{allSubtitleTracksForTranscodingStream?.map(
|
||||||
|
(sub, idx: number) => (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
value={
|
||||||
|
subtitleIndex ===
|
||||||
|
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||||
|
? subtitleHelper
|
||||||
|
.getSourceSubtitleIndex(sub.index)
|
||||||
|
.toString()
|
||||||
|
: sub?.index.toString())
|
||||||
|
}
|
||||||
|
key={`subtitle-item-${idx}`}
|
||||||
|
onValueChange={() => {
|
||||||
|
console.log("sub", sub);
|
||||||
|
if (
|
||||||
|
subtitleIndex ===
|
||||||
|
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||||
|
? subtitleHelper
|
||||||
|
.getSourceSubtitleIndex(sub.index)
|
||||||
|
.toString()
|
||||||
|
: sub?.index.toString())
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
router.setParams({
|
||||||
|
subtitleIndex: subtitleHelper
|
||||||
|
.getSourceSubtitleIndex(sub.index)
|
||||||
|
.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||||
|
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changeToImageBasedSub(sub.index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||||
|
{sub.name}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||||
|
Audio
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
{allAudio?.map((track, idx: number) => (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key={`audio-item-${idx}`}
|
||||||
|
value={audioIndex === track.index.toString()}
|
||||||
|
onValueChange={() => {
|
||||||
|
if (audioIndex === track.index.toString()) return;
|
||||||
|
console.log("Setting audio track to: ", track.index);
|
||||||
|
router.setParams({
|
||||||
|
audioIndex: track.index.toString(),
|
||||||
|
});
|
||||||
|
ChangeTranscodingAudio(track.index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||||
|
{track.name}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownView;
|
||||||
19
components/video-player/controls/types.ts
Normal file
19
components/video-player/controls/types.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
type EmbeddedSubtitle = {
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExternalSubtitle = {
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
isExternal: boolean;
|
||||||
|
deliveryUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TranscodedSubtitle = {
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
IsTextSubtitleStream: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
|
||||||
73
components/vlc/VideoDebugInfo.tsx
Normal file
73
components/vlc/VideoDebugInfo.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
TrackInfo,
|
||||||
|
VlcPlayerViewRef,
|
||||||
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
playerRef: React.RefObject<VlcPlayerViewRef>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
||||||
|
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
||||||
|
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTracks = async () => {
|
||||||
|
if (playerRef.current) {
|
||||||
|
const audio = await playerRef.current.getAudioTracks();
|
||||||
|
const subtitles = await playerRef.current.getSubtitleTracks();
|
||||||
|
setAudioTracks(audio);
|
||||||
|
setSubtitleTracks(subtitles);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTracks();
|
||||||
|
}, [playerRef]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top,
|
||||||
|
left: insets.left + 8,
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text className="font-bold">Playback State:</Text>
|
||||||
|
<Text className="font-bold mt-2.5">Audio Tracks:</Text>
|
||||||
|
{audioTracks &&
|
||||||
|
audioTracks.map((track, index) => (
|
||||||
|
<Text key={index}>
|
||||||
|
{track.name} (Index: {track.index})
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text className="font-bold mt-2.5">Subtitle Tracks:</Text>
|
||||||
|
{subtitleTracks &&
|
||||||
|
subtitleTracks.map((track, index) => (
|
||||||
|
<Text key={index}>
|
||||||
|
{track.name} (Index: {track.index})
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<TouchableOpacity
|
||||||
|
className="mt-2.5 bg-blue-500 p-2 rounded"
|
||||||
|
onPress={() => {
|
||||||
|
if (playerRef.current) {
|
||||||
|
playerRef.current.getAudioTracks().then(setAudioTracks);
|
||||||
|
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-white text-center">Refresh Tracks</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.17.0",
|
"channel": "0.22.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.17.0",
|
"channel": "0.22.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
15
edge-to-edge-fix.patch
Normal file
15
edge-to-edge-fix.patch
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
--- expo.js.original 2024-11-10 09:08:19
|
||||||
|
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
|
||||||
|
@@ -19,10 +19,8 @@
|
||||||
|
const {
|
||||||
|
barStyle
|
||||||
|
} = androidStatusBar;
|
||||||
|
+ const android = props?.android || {};
|
||||||
|
const {
|
||||||
|
- android = {}
|
||||||
|
- } = props;
|
||||||
|
- const {
|
||||||
|
parentTheme = "Default"
|
||||||
|
} = android;
|
||||||
|
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
|
||||||
|
\ No newline at end of file
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import index from "@/app/(auth)/(tabs)/(home)";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
interface AdjacentEpisodesProps {
|
interface AdjacentEpisodesProps {
|
||||||
@@ -12,88 +12,53 @@ interface AdjacentEpisodesProps {
|
|||||||
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
const { data: previousItem } = useQuery({
|
const { data: adjacentItems } = useQuery({
|
||||||
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||||
const parentId = item?.AlbumId || item?.ParentId;
|
if (!api || !item || !item.SeriesId) {
|
||||||
const indexNumber = item?.IndexNumber;
|
|
||||||
|
|
||||||
console.log("Getting previous item for " + indexNumber);
|
|
||||||
if (
|
|
||||||
!api ||
|
|
||||||
!parentId ||
|
|
||||||
indexNumber === undefined ||
|
|
||||||
indexNumber === null ||
|
|
||||||
indexNumber - 1 < 1
|
|
||||||
) {
|
|
||||||
console.log("No previous item", {
|
|
||||||
itemIndex: indexNumber,
|
|
||||||
itemId: item?.Id,
|
|
||||||
parentId: parentId,
|
|
||||||
indexNumber: indexNumber,
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newIndexNumber = indexNumber - 2;
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId,
|
||||||
const res = await getItemsApi(api).getItems({
|
adjacentTo: item.Id,
|
||||||
parentId: parentId!,
|
limit: 3,
|
||||||
startIndex: newIndexNumber,
|
|
||||||
limit: 1,
|
|
||||||
sortBy: ["IndexNumber"],
|
|
||||||
includeItemTypes: ["Episode", "Audio"],
|
|
||||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber - 1) {
|
return res.data.Items || null;
|
||||||
throw new Error("Previous item is not correct");
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.data.Items?.[0] || null;
|
|
||||||
},
|
},
|
||||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
enabled:
|
||||||
|
!!api &&
|
||||||
|
!!item?.Id &&
|
||||||
|
!!item?.SeriesId &&
|
||||||
|
(item?.Type === "Episode" || item?.Type === "Audio"),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: nextItem } = useQuery({
|
const previousItem = useMemo(() => {
|
||||||
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
return null;
|
||||||
const parentId = item?.AlbumId || item?.ParentId;
|
}
|
||||||
const indexNumber = item?.IndexNumber;
|
|
||||||
|
|
||||||
if (
|
if (adjacentItems.length === 2) {
|
||||||
!api ||
|
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||||
!parentId ||
|
}
|
||||||
indexNumber === undefined ||
|
|
||||||
indexNumber === null
|
|
||||||
) {
|
|
||||||
console.log("No next item", {
|
|
||||||
itemId: item?.Id,
|
|
||||||
parentId: parentId,
|
|
||||||
indexNumber: indexNumber,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getItemsApi(api).getItems({
|
return adjacentItems[0];
|
||||||
parentId: parentId!,
|
}, [adjacentItems, item]);
|
||||||
startIndex: indexNumber,
|
|
||||||
sortBy: ["IndexNumber"],
|
|
||||||
limit: 1,
|
|
||||||
includeItemTypes: ["Episode", "Audio"],
|
|
||||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber + 1) {
|
const nextItem = useMemo(() => {
|
||||||
throw new Error("Previous item is not correct");
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return res.data.Items?.[0] || null;
|
if (adjacentItems.length === 2) {
|
||||||
},
|
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
}
|
||||||
staleTime: 0,
|
|
||||||
});
|
return adjacentItems[2];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
return { previousItem, nextItem };
|
return { previousItem, nextItem };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import * as NavigationBar from "expo-navigation-bar";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
export const useAndroidNavigationBar = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
NavigationBar.setVisibilityAsync("hidden");
|
|
||||||
NavigationBar.setBehaviorAsync("overlay-swipe");
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
NavigationBar.setVisibilityAsync("visible");
|
|
||||||
NavigationBar.setBehaviorAsync("inset-swipe");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
@@ -4,6 +4,8 @@ import { useAtom } from "jotai";
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface CreditTimestamps {
|
interface CreditTimestamps {
|
||||||
Introduction: {
|
Introduction: {
|
||||||
@@ -21,16 +23,29 @@ interface CreditTimestamps {
|
|||||||
export const useCreditSkipper = (
|
export const useCreditSkipper = (
|
||||||
itemId: string | undefined,
|
itemId: string | undefined,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
videoRef: React.RefObject<any>
|
seek: (time: number) => void,
|
||||||
|
play: () => void,
|
||||||
|
isVlc: boolean = false
|
||||||
) => {
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
|
|
||||||
|
if (isVlc) {
|
||||||
|
currentTime = msToSeconds(currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrappedSeek = (seconds: number) => {
|
||||||
|
if (isVlc) {
|
||||||
|
seek(secondsToMs(seconds));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seek(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
||||||
queryKey: ["creditTimestamps", itemId],
|
queryKey: ["creditTimestamps", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
console.log("No item id");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,17 +76,18 @@ export const useCreditSkipper = (
|
|||||||
}, [creditTimestamps, currentTime]);
|
}, [creditTimestamps, currentTime]);
|
||||||
|
|
||||||
const skipCredit = useCallback(() => {
|
const skipCredit = useCallback(() => {
|
||||||
console.log("skipCredits");
|
if (!creditTimestamps) return;
|
||||||
if (!creditTimestamps || !videoRef.current) return;
|
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
||||||
try {
|
try {
|
||||||
videoRef.current.seek(creditTimestamps.Credits.End);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
wrappedSeek(creditTimestamps.Credits.End);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
videoRef.current?.resume();
|
play();
|
||||||
}, 200);
|
}, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
writeToLog("ERROR", "Error skipping intro", error);
|
||||||
}
|
}
|
||||||
}, [creditTimestamps, videoRef]);
|
}, [creditTimestamps]);
|
||||||
|
|
||||||
return { showSkipCreditButton, skipCredit };
|
return { showSkipCreditButton, skipCredit };
|
||||||
};
|
};
|
||||||
|
|||||||
50
hooks/useDefaultPlaySettings.ts
Normal file
50
hooks/useDefaultPlaySettings.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { Settings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
// Used only for intial play settings.
|
||||||
|
const useDefaultPlaySettings = (
|
||||||
|
item: BaseItemDto,
|
||||||
|
settings: Settings | null
|
||||||
|
) => {
|
||||||
|
const playSettings = useMemo(() => {
|
||||||
|
// 1. Get first media source
|
||||||
|
const mediaSource = item.MediaSources?.[0];
|
||||||
|
|
||||||
|
// 2. Get default or preferred audio
|
||||||
|
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||||
|
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
|
(x) =>
|
||||||
|
x.Type === "Audio" &&
|
||||||
|
x.Language ===
|
||||||
|
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName
|
||||||
|
)?.Index;
|
||||||
|
|
||||||
|
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
|
(x) => x.Type === "Audio"
|
||||||
|
)?.Index;
|
||||||
|
|
||||||
|
// 4. Get default bitrate
|
||||||
|
const bitrate = BITRATES[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultAudioIndex:
|
||||||
|
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
||||||
|
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex || -1,
|
||||||
|
defaultMediaSource: mediaSource || undefined,
|
||||||
|
defaultBitrate: bitrate || undefined,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
item.MediaSources,
|
||||||
|
settings?.defaultAudioLanguage,
|
||||||
|
settings?.defaultSubtitleLanguage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return playSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDefaultPlaySettings;
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// hooks/useFileOpener.ts
|
|
||||||
|
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -7,46 +5,44 @@ import * as FileSystem from "expo-file-system";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
export const useFileOpener = () => {
|
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
||||||
|
const directory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
throw new Error("Document directory is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
throw new Error("Item ID is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await FileSystem.readDirectoryAsync(directory);
|
||||||
|
const path = itemId!;
|
||||||
|
const matchingFile = files.find((file) => file.startsWith(path));
|
||||||
|
|
||||||
|
if (!matchingFile) {
|
||||||
|
throw new Error(`No file found for item ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${directory}${matchingFile}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDownloadedFileOpener = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||||
|
|
||||||
const openFile = useCallback(async (item: BaseItemDto) => {
|
const openFile = useCallback(
|
||||||
const directory = FileSystem.documentDirectory;
|
async (item: BaseItemDto) => {
|
||||||
|
try {
|
||||||
if (!directory) {
|
// @ts-expect-error
|
||||||
throw new Error("Document directory is not available");
|
router.push("/player/direct-player?offline=true&itemId=" + item.Id);
|
||||||
}
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error opening file", error);
|
||||||
if (!item.Id) {
|
console.error("Error opening file:", error);
|
||||||
throw new Error("Item ID is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const files = await FileSystem.readDirectoryAsync(directory);
|
|
||||||
for (let f of files) {
|
|
||||||
console.log(f);
|
|
||||||
}
|
}
|
||||||
const path = item.Id!;
|
},
|
||||||
const matchingFile = files.find((file) => file.startsWith(path));
|
[setOfflineSettings, setPlayUrl, router]
|
||||||
|
);
|
||||||
if (!matchingFile) {
|
|
||||||
throw new Error(`No file found for item ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${directory}${matchingFile}`;
|
|
||||||
|
|
||||||
setOfflineSettings({
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
setPlayUrl(url);
|
|
||||||
|
|
||||||
router.push("/play-offline-video");
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error opening file", error);
|
|
||||||
console.error("Error opening file:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { openFile };
|
return { openFile };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export const useImageColors = ({
|
|||||||
|
|
||||||
// If colors are cached, use them and exit
|
// If colors are cached, use them and exit
|
||||||
if (_primary && _text) {
|
if (_primary && _text) {
|
||||||
console.info("useImageColors ~ Using cached colors for performance.");
|
|
||||||
setPrimaryColor({
|
setPrimaryColor({
|
||||||
primary: _primary,
|
primary: _primary,
|
||||||
text: _text,
|
text: _text,
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { useState, useCallback } from "react";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
const useImageStorage = () => {
|
const useImageStorage = () => {
|
||||||
const saveBase64Image = useCallback(async (base64: string, key: string) => {
|
const saveBase64Image = useCallback(async (base64: string, key: string) => {
|
||||||
try {
|
try {
|
||||||
// Save the base64 string to AsyncStorage
|
// Save the base64 string to storage
|
||||||
storage.set(key, base64);
|
storage.set(key, base64);
|
||||||
console.log("Image saved successfully");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving image:", error);
|
console.error("Error saving image:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -70,7 +67,7 @@ const useImageStorage = () => {
|
|||||||
|
|
||||||
const loadImage = useCallback(async (key: string) => {
|
const loadImage = useCallback(async (key: string) => {
|
||||||
try {
|
try {
|
||||||
// Retrieve the base64 string from AsyncStorage
|
// Retrieve the base64 string from storage
|
||||||
const base64Image = storage.getString(key);
|
const base64Image = storage.getString(key);
|
||||||
if (base64Image !== null) {
|
if (base64Image !== null) {
|
||||||
// Set the loaded image state
|
// Set the loaded image state
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useAtom } from "jotai";
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface IntroTimestamps {
|
interface IntroTimestamps {
|
||||||
EpisodeId: string;
|
EpisodeId: string;
|
||||||
@@ -14,19 +16,36 @@ interface IntroTimestamps {
|
|||||||
Valid: boolean;
|
Valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle skipping intros in a media player.
|
||||||
|
*
|
||||||
|
* @param {number} currentTime - The current playback time in seconds.
|
||||||
|
*/
|
||||||
export const useIntroSkipper = (
|
export const useIntroSkipper = (
|
||||||
itemId: string | undefined,
|
itemId: string | undefined,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
videoRef: React.RefObject<any>
|
seek: (ticks: number) => void,
|
||||||
|
play: () => void,
|
||||||
|
isVlc: boolean = false
|
||||||
) => {
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||||
|
if (isVlc) {
|
||||||
|
currentTime = msToSeconds(currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrappedSeek = (seconds: number) => {
|
||||||
|
if (isVlc) {
|
||||||
|
seek(secondsToMs(seconds));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seek(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
||||||
queryKey: ["introTimestamps", itemId],
|
queryKey: ["introTimestamps", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
console.log("No item id");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,16 +77,17 @@ export const useIntroSkipper = (
|
|||||||
|
|
||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
console.log("skipIntro");
|
console.log("skipIntro");
|
||||||
if (!introTimestamps || !videoRef.current) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
videoRef.current.seek(introTimestamps.IntroEnd);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
wrappedSeek(introTimestamps.IntroEnd);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
videoRef.current?.resume();
|
play();
|
||||||
}, 200);
|
}, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
writeToLog("ERROR", "Error skipping intro", error);
|
||||||
}
|
}
|
||||||
}, [introTimestamps, videoRef]);
|
}, [introTimestamps]);
|
||||||
|
|
||||||
return { showSkipButton, skipIntro };
|
return { showSkipButton, skipIntro };
|
||||||
};
|
};
|
||||||
|
|||||||
88
hooks/useMarkAsPlayed.ts
Normal file
88
hooks/useMarkAsPlayed.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
||||||
|
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const invalidateQueries = () => {
|
||||||
|
const queriesToInvalidate = [
|
||||||
|
["item", item.Id],
|
||||||
|
["resumeItems"],
|
||||||
|
["continueWatching"],
|
||||||
|
["nextUp-all"],
|
||||||
|
["nextUp"],
|
||||||
|
["episodes"],
|
||||||
|
["seasons"],
|
||||||
|
["home"],
|
||||||
|
];
|
||||||
|
|
||||||
|
queriesToInvalidate.forEach((queryKey) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAsPlayedStatus = async (played: boolean) => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["item", item.Id],
|
||||||
|
(oldData: BaseItemDto | undefined) => {
|
||||||
|
if (oldData) {
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
UserData: {
|
||||||
|
...oldData.UserData,
|
||||||
|
Played: !played,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (played) {
|
||||||
|
await markAsNotPlayed({
|
||||||
|
api: api,
|
||||||
|
itemId: item?.Id,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await markAsPlayed({
|
||||||
|
api: api,
|
||||||
|
item: item,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
invalidateQueries();
|
||||||
|
} catch (error) {
|
||||||
|
// Revert optimistic update on error
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["item", item.Id],
|
||||||
|
(oldData: BaseItemDto | undefined) => {
|
||||||
|
if (oldData) {
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
UserData: {
|
||||||
|
...oldData.UserData,
|
||||||
|
Played: played,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
console.error("Error updating played status:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return markAsPlayedStatus;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user