forked from Ninjalama/streamyfin_mirror
Compare commits
271 Commits
fix/test
...
fix/playba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
187f504d86 | ||
|
|
e651b975b7 | ||
|
|
1c550b1b77 | ||
|
|
5bcae81538 | ||
|
|
c951725222 | ||
|
|
0b966d7c04 | ||
|
|
8e0e35afe3 | ||
|
|
daf7f35196 | ||
|
|
d5ac30b6d8 | ||
|
|
81b91bbb97 | ||
|
|
af2bd030e9 | ||
|
|
5590c2f784 | ||
|
|
6cc70dd123 | ||
|
|
fae588b0f0 | ||
|
|
bd2aeb2234 | ||
|
|
cca0bbf42c | ||
|
|
06e0eb5c4e | ||
|
|
b478fbb6bf | ||
|
|
b98a7b0634 | ||
|
|
ce38024a3f | ||
|
|
04dce9265b | ||
|
|
5b8418cd82 | ||
|
|
b0c5255bd7 | ||
|
|
73dd171987 | ||
|
|
ff35559687 | ||
|
|
5aadd50946 | ||
|
|
63b5ba2112 | ||
|
|
8b955578a2 | ||
|
|
1e5c021c93 | ||
|
|
0b86f56486 | ||
|
|
728b93f4e5 | ||
|
|
2fc483b24e | ||
|
|
fc901bc01e | ||
|
|
2b0884b154 | ||
|
|
307d20e538 | ||
|
|
a2f03908f6 | ||
|
|
77aef8877e | ||
|
|
0cf930d6e1 | ||
|
|
4b0b949541 | ||
|
|
14b717f985 | ||
|
|
cfbac538f8 | ||
|
|
1ac6b7e3df | ||
|
|
c9f6e8676b | ||
|
|
5aab1450cd | ||
|
|
1e7080a136 | ||
|
|
993cec4138 | ||
|
|
6c524499f9 | ||
|
|
b3463ffdfc | ||
|
|
50942b44f1 | ||
|
|
f602f8919f | ||
|
|
0e86d8a00f | ||
|
|
56b1e1977c | ||
|
|
30e23b9079 | ||
|
|
d83ecb881b | ||
|
|
4c14c08b35 | ||
|
|
ecb9b90163 | ||
|
|
33a2be24f4 | ||
|
|
e8b0d52515 | ||
|
|
9faa0de2d6 | ||
|
|
221155d002 | ||
|
|
4a37e17324 | ||
|
|
52b2a3418e | ||
|
|
2753b243e5 | ||
|
|
f22b356b7c | ||
|
|
d8ba5af8d9 | ||
|
|
505ef39ee7 | ||
|
|
e71d5cc176 | ||
|
|
74e57bbd88 | ||
|
|
76eaeb9820 | ||
|
|
9a70f98dd5 | ||
|
|
f28f1d8736 | ||
|
|
e0f03ccb93 | ||
|
|
34d1dbb20e | ||
|
|
e3e2db659d | ||
|
|
528b4ad7ac | ||
|
|
d29501386b | ||
|
|
6688469b6c | ||
|
|
ae9c30aa6d | ||
|
|
364d2e8a51 | ||
|
|
6cc90b46b3 | ||
|
|
33adea2819 | ||
|
|
9f41861dcf | ||
|
|
2b2d23e574 | ||
|
|
f6e2bcb120 | ||
|
|
314cd62bee | ||
|
|
41e7123d1c | ||
|
|
2af42b39f5 | ||
|
|
0a06b336c8 | ||
|
|
028c9159f3 | ||
|
|
dee4fa07e3 | ||
|
|
2764f1736a | ||
|
|
d3d1a7bcde | ||
|
|
7fcd598fa1 | ||
|
|
0fc1506b11 | ||
|
|
e0aa7ea0df | ||
|
|
25f77645f8 | ||
|
|
1c81091e8b | ||
|
|
94502b558d | ||
|
|
a7d7d00eb3 | ||
|
|
3b5e07c1d2 | ||
|
|
db10369fb5 | ||
|
|
32da5918c7 | ||
|
|
dc542021b5 | ||
|
|
bfad157a28 | ||
|
|
a71a646743 | ||
|
|
366bc0137e | ||
|
|
3eb60840e6 | ||
|
|
65c4a1340d | ||
|
|
3e90447dd4 | ||
|
|
bd0768797e | ||
|
|
730ef4616f | ||
|
|
c4d4475aa9 | ||
|
|
d1eb40f2a9 | ||
|
|
77518d774e | ||
|
|
a6fb7b956d | ||
|
|
034ff3f478 | ||
|
|
98ca4e7a6d | ||
|
|
461a276a20 | ||
|
|
3975473da9 | ||
|
|
d34b86297a | ||
|
|
c4a83e283f | ||
|
|
dac471f0a6 | ||
|
|
252fc4387b | ||
|
|
3e299e2136 | ||
|
|
01cab2277e | ||
|
|
e4f4e861e0 | ||
|
|
4d665013f0 | ||
|
|
9aa4ea4a2e | ||
|
|
93ae03f55c | ||
|
|
b311ac98a7 | ||
|
|
83d425b2fb | ||
|
|
007fbdd0a3 | ||
|
|
37df999db5 | ||
|
|
72b9675df4 | ||
|
|
7a30a63335 | ||
|
|
0ff0fab3f4 | ||
|
|
d9d9b0ee00 | ||
|
|
fdaa69a787 | ||
|
|
ed5403e597 | ||
|
|
e6f290b85f | ||
|
|
aa20d9c701 | ||
|
|
e7128afb32 | ||
|
|
a24b126539 | ||
|
|
e1fe20db86 | ||
|
|
cd9f6aa8bd | ||
|
|
747bd1b416 | ||
|
|
364ce46fe5 | ||
|
|
5703279b46 | ||
|
|
4022ccb213 | ||
|
|
3a836462f5 | ||
|
|
8a5f24002f | ||
|
|
c30f9860ee | ||
|
|
94c170e3d2 | ||
|
|
cd8aba32d8 | ||
|
|
15f3ddf612 | ||
|
|
90f20f6e46 | ||
|
|
ea1f45bbaf | ||
|
|
7e62c9bc9a | ||
|
|
23f9e9dfae | ||
|
|
580e12b605 | ||
|
|
ff4c5f28af | ||
|
|
1b931ea348 | ||
|
|
49c0437f81 | ||
|
|
d81ae94ce8 | ||
|
|
7c77c70024 | ||
|
|
b28c4a56f3 | ||
|
|
2495a318eb | ||
|
|
7832ea4d0a | ||
|
|
4a0a51ef1d | ||
|
|
8cc551d906 | ||
|
|
c8da365a00 | ||
|
|
74b7cbc530 | ||
|
|
a14063a736 | ||
|
|
a3307a90a3 | ||
|
|
a2145fd7e8 | ||
|
|
cab5e4d980 | ||
|
|
ab603e6997 | ||
|
|
957348fe19 | ||
|
|
444bd040b0 | ||
|
|
d0ae63235d | ||
|
|
1727125ea7 | ||
|
|
dc498d62d8 | ||
|
|
455bf08213 | ||
|
|
0f974ef2a3 | ||
|
|
2d9aaccfe0 | ||
|
|
2c6823eb53 | ||
|
|
9dfcc01f17 | ||
|
|
38aad9610b | ||
|
|
54af64abef | ||
|
|
e1720a00da | ||
|
|
882d0ea188 | ||
|
|
f3b539232f | ||
|
|
33ea657a5c | ||
|
|
75820adcbc | ||
|
|
76cdb2b3f8 | ||
|
|
0a2ea33635 | ||
|
|
3cd8e41000 | ||
|
|
aad6093852 | ||
|
|
c553cff9d1 | ||
|
|
dcd458bd3d | ||
|
|
05dc61d17d | ||
|
|
e4de11127f | ||
|
|
2dc49735f4 | ||
|
|
0ebacd4bd3 | ||
|
|
14c8c1aaed | ||
|
|
2da774272d | ||
|
|
dd08826931 | ||
|
|
b681025389 | ||
|
|
ef42207174 | ||
|
|
efa5638b12 | ||
|
|
65549428bf | ||
|
|
c63cea891d | ||
|
|
4e80f58823 | ||
|
|
cda3b64a2b | ||
|
|
cfe39d504c | ||
|
|
cf43d1a657 | ||
|
|
cbe3b18226 | ||
|
|
b637a0f7d2 | ||
|
|
a0ce7cc6d0 | ||
|
|
a640df30bc | ||
|
|
062e6e6c23 | ||
|
|
d709e3b13e | ||
|
|
b232bebd73 | ||
|
|
90ef8ef6f9 | ||
|
|
0df6b8e2a0 | ||
|
|
f48b26076d | ||
|
|
c86a8438e5 | ||
|
|
373d4ca3b1 | ||
|
|
8bc360d554 | ||
|
|
3fae21d559 | ||
|
|
74ce9d7eea | ||
|
|
5055a700c9 | ||
|
|
faa2baae68 | ||
|
|
ed42371353 | ||
|
|
ab33693dd9 | ||
|
|
480abb216d | ||
|
|
249109a94e | ||
|
|
eb7fa93f9b | ||
|
|
e8fd322d30 | ||
|
|
6a4621c377 | ||
|
|
2fb19f601b | ||
|
|
a602c35a8f | ||
|
|
46ac4a2cc7 | ||
|
|
962f65874e | ||
|
|
53ea1cc899 | ||
|
|
459ca3245b | ||
|
|
0d1fb87284 | ||
|
|
495742c52c | ||
|
|
894305e126 | ||
|
|
ed993d07ce | ||
|
|
dc9008f31c | ||
|
|
e23387a384 | ||
|
|
bb141cad57 | ||
|
|
e833b4bc68 | ||
|
|
34fc26ed18 | ||
|
|
40b8410390 | ||
|
|
723233381c | ||
|
|
602de34824 | ||
|
|
9b1f2a98e5 | ||
|
|
946de97580 | ||
|
|
f2eadabf6a | ||
|
|
373d83a0d5 | ||
|
|
2c0ba18b49 | ||
|
|
3e8e8e1163 | ||
|
|
fe9c73a8f0 | ||
|
|
4f62391027 | ||
|
|
53b5fdda87 | ||
|
|
c0b71eb73d | ||
|
|
9b4590c876 | ||
|
|
4b18bad3bc | ||
|
|
752cb1cdc6 |
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,7 +43,12 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.27.0
|
||||||
|
- 0.26.1
|
||||||
|
- 0.26.0
|
||||||
|
- 0.25.0
|
||||||
- 0.24.0
|
- 0.24.0
|
||||||
|
- 0.23.0
|
||||||
- 0.22.0
|
- 0.22.0
|
||||||
- 0.21.0
|
- 0.21.0
|
||||||
- older
|
- older
|
||||||
|
|||||||
41
.github/workflows/lint-pr.yaml
vendored
Normal file
41
.github/workflows/lint-pr.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: "Lint PR"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
name: Validate PR title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
|
id: lint_pr_title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey there and thank you for opening this pull request! 👋🏼
|
||||||
|
|
||||||
|
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
delete: true
|
||||||
39
.github/workflows/main.yml
vendored
Normal file
39
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Handle Stale Issues
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
# Issue specific settings
|
||||||
|
days-before-issue-stale: 90
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
stale-issue-message: |
|
||||||
|
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
|
||||||
|
|
||||||
|
If this issue is still relevant, please leave a comment to keep it open.
|
||||||
|
Otherwise, it will be closed in 7 days if no further activity occurs.
|
||||||
|
|
||||||
|
Thank you for your contributions!
|
||||||
|
close-issue-message: |
|
||||||
|
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
|
||||||
|
|
||||||
|
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
|
||||||
|
|
||||||
|
# Pull request settings (disabled)
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
|
||||||
|
# Other settings
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
operations-per-run: 100
|
||||||
|
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -10,6 +10,7 @@ npm-debug.*
|
|||||||
*.orig.*
|
*.orig.*
|
||||||
web-build/
|
web-build/
|
||||||
modules/vlc-player/android/build
|
modules/vlc-player/android/build
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -26,6 +27,10 @@ package-lock.json
|
|||||||
|
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
/iostv
|
||||||
|
/iosmobile
|
||||||
|
/androidmobile
|
||||||
|
/androidtv
|
||||||
|
|
||||||
modules/player/android
|
modules/player/android
|
||||||
|
|
||||||
@@ -35,4 +40,7 @@ credentials.json
|
|||||||
*.ipa
|
*.ipa
|
||||||
.continuerc.json
|
.continuerc.json
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.ruby-lsp
|
||||||
|
modules/hls-downloader/android/build
|
||||||
|
|||||||
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
329
.idea/caches/deviceStreaming.xml
generated
329
.idea/caches/deviceStreaming.xml
generated
@@ -1,329 +0,0 @@
|
|||||||
<?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
6
.idea/misc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?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
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?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
9
.idea/streamyfin.iml
generated
@@ -1,9 +0,0 @@
|
|||||||
<?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
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -9,6 +9,7 @@
|
|||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
|
"prettier.printWidth": 120,
|
||||||
"[swift]": {
|
"[swift]": {
|
||||||
"editor.defaultFormatter": "sswg.swift-lang"
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
}
|
}
|
||||||
|
|||||||
6
Makefile
Normal file
6
Makefile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
e2e:
|
||||||
|
maestro start-device --platform android
|
||||||
|
maestro test login.yaml
|
||||||
|
|
||||||
|
e2e-setup:
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
118
README.md
118
README.md
@@ -8,7 +8,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||||
<img width=150 src="./assets/images/jellyseerr.PNG"/>
|
<img width=159 src="./assets/images/jellyseerr.PNG"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
@@ -18,6 +18,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||||
|
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
@@ -32,22 +33,17 @@ Downloading works by using ffmpeg to convert an HLS stream into a video file on
|
|||||||
|
|
||||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||||
|
|
||||||
## Plugins
|
### Streamyfin Plugin
|
||||||
|
|
||||||
In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
||||||
|
|
||||||
### Collection rows
|
- Auto log in to Jellyseerr without the user having to do anythin
|
||||||
|
- Choose the default languages
|
||||||
|
- Set download method and search provider
|
||||||
|
- Customize homescreen
|
||||||
|
- And more...
|
||||||
|
|
||||||
Jellyfin collections can be shown as rows or carousel on the home screen.
|
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||||
The following tags can be added to a collection to provide this functionality.
|
|
||||||
|
|
||||||
Available tags:
|
|
||||||
|
|
||||||
- sf_promoted: will make the collection a row at home
|
|
||||||
- sf_carousel: will make the collection a carousel on home.
|
|
||||||
|
|
||||||
A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc.
|
|
||||||
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
|
|
||||||
|
|
||||||
### Jellysearch
|
### Jellysearch
|
||||||
|
|
||||||
@@ -70,9 +66,9 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
|
|||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
@@ -89,8 +85,14 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
|||||||
|
|
||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed.
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
|
4. run `npm run prebuild`
|
||||||
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
||||||
|
|
||||||
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
|
`npm run prebuild:tv`
|
||||||
|
`npm run ios:tv or npm run android:tv`
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -121,7 +123,85 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
|
|||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
I'd like to thank the following people and projects for their contributions to Streamyfin:
|
### Core Developers
|
||||||
|
|
||||||
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Alexk2309">
|
||||||
|
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Alexk2309</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/herrrta">
|
||||||
|
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@herrrta</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/lostb1t">
|
||||||
|
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@lostb1t</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Simon-Eklundh">
|
||||||
|
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Simon-Eklundh</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/topiga">
|
||||||
|
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@topiga</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/simoncaron">
|
||||||
|
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@simoncaron</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/jakequade">
|
||||||
|
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@jakequade</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Ryan0204">
|
||||||
|
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Ryan0204</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/retardgerman">
|
||||||
|
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@retardgerman</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/whoopsi-daisy">
|
||||||
|
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||||
|
|
||||||
|
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
||||||
|
|
||||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||||
|
|||||||
11
app.config.js
Normal file
11
app.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
if (process.env.EXPO_TV != "1") {
|
||||||
|
config.plugins.push([
|
||||||
|
"react-native-google-cast",
|
||||||
|
{ useDefaultExpandedMediaControls: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
};
|
||||||
51
app.json
51
app.json
@@ -2,16 +2,11 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.24.0",
|
"version": "0.27.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"splash": {
|
|
||||||
"image": "./assets/images/splash.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#2E2E2E"
|
|
||||||
},
|
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
"ios": {
|
"ios": {
|
||||||
@@ -36,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 50,
|
"versionCode": 53,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
@@ -48,15 +43,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"@config-plugins/ffmpeg-kit-react-native",
|
"@config-plugins/ffmpeg-kit-react-native",
|
||||||
[
|
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
@@ -78,18 +68,19 @@
|
|||||||
"useFrameworks": "static"
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"android": {
|
"compileSdkVersion": 35,
|
||||||
"compileSdkVersion": 34,
|
"targetSdkVersion": 35,
|
||||||
"targetSdkVersion": 34,
|
"buildToolsVersion": "35.0.0",
|
||||||
"buildToolsVersion": "34.0.0"
|
"kotlinVersion": "2.0.21",
|
||||||
},
|
|
||||||
"minSdkVersion": 24,
|
"minSdkVersion": 24,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
"jniLibs": {
|
"jniLibs": {
|
||||||
"useLegacyPackaging": true
|
"useLegacyPackaging": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"useAndroidX": true,
|
||||||
|
"enableJetifier": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -105,14 +96,29 @@
|
|||||||
"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-localization",
|
||||||
"expo-asset",
|
"expo-asset",
|
||||||
[
|
[
|
||||||
"react-native-edge-to-edge",
|
"react-native-edge-to-edge",
|
||||||
{ "android": { "parentTheme": "Material3" } }
|
{
|
||||||
|
"android": {
|
||||||
|
"parentTheme": "Material3"
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
["react-native-bottom-tabs"],
|
["react-native-bottom-tabs"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./plugins/withGoogleCastActivity.js"]
|
["./plugins/withAndroidManifest.js"],
|
||||||
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
|
["./plugins/withGradleProperties.js"],
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"backgroundColor": "#2e2e2e",
|
||||||
|
"image": "./assets/images/StreamyFinFinal.png",
|
||||||
|
"imageWidth": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -131,6 +137,7 @@
|
|||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
}
|
},
|
||||||
|
"newArchEnabled": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {Stack} from "expo-router";
|
import {Stack} from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function CustomMenuLayout() {
|
export default function CustomMenuLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -9,7 +11,7 @@ export default function CustomMenuLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Custom Links",
|
headerTitle: t("tabs.custom_links"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai/index";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import * as WebBrowser from "expo-web-browser";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||||
|
|
||||||
export interface MenuLink {
|
export interface MenuLink {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -18,6 +21,7 @@ export default function menuLinks() {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getMenuLinks = useCallback(async () => {
|
const getMenuLinks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -50,7 +54,13 @@ export default function menuLinks() {
|
|||||||
}}
|
}}
|
||||||
data={menuLinks}
|
data={menuLinks}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
WebBrowser.openBrowserAsync(item.url);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={item.name}
|
title={item.name}
|
||||||
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
||||||
@@ -67,7 +77,7 @@ export default function menuLinks() {
|
|||||||
)}
|
)}
|
||||||
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 links</Text>
|
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -10,7 +12,7 @@ export default function SearchLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Favorites",
|
headerTitle: t("tabs.favorites"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Ionicons, 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";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -14,7 +20,7 @@ export default function IndexLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Home",
|
headerTitle: t("tabs.home"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
@@ -23,14 +29,15 @@ export default function IndexLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast />
|
{!Platform.isTV && (
|
||||||
<TouchableOpacity
|
<>
|
||||||
onPress={() => {
|
<Chromecast.Chromecast />
|
||||||
router.push("/(auth)/settings");
|
{user && user.Policy?.IsAdministrator && (
|
||||||
}}
|
<SessionsButton />
|
||||||
>
|
)}
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
<SettingsButton />
|
||||||
</TouchableOpacity>
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@@ -38,19 +45,25 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="downloads/index"
|
name="downloads/index"
|
||||||
options={{
|
options={{
|
||||||
title: "Downloads",
|
title: t("home.downloads.downloads_title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="downloads/[seriesId]"
|
name="downloads/[seriesId]"
|
||||||
options={{
|
options={{
|
||||||
title: "TV-Series",
|
title: t("home.downloads.tvseries"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="sessions/index"
|
||||||
|
options={{
|
||||||
|
title: t("home.sessions.title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: t("home.settings.settings_title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -65,6 +78,12 @@ export default function IndexLayout() {
|
|||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/dashboard/sessions"
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.dashboard.sessions_title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/jellyseerr/page"
|
name="settings/jellyseerr/page"
|
||||||
options={{
|
options={{
|
||||||
@@ -72,13 +91,13 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/popular-lists/page"
|
name="settings/hide-libraries/page"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/hide-libraries/page"
|
name="settings/logs/page"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
@@ -107,3 +126,38 @@ export default function IndexLayout() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SettingsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/settings");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { sessions = [], _ } = useSessions({} as useSessionsProps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/sessions");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="mr-4">
|
||||||
|
<Ionicons
|
||||||
|
name="play-circle"
|
||||||
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
|
size={25}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MovieCard } from "@/components/downloads/MovieCard";
|
|||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -12,6 +12,8 @@ import React, { useEffect, useMemo, useRef } from "react";
|
|||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from 'i18next';
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -24,6 +26,7 @@ import { writeToLog } from "@/utils/log";
|
|||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -70,17 +73,17 @@ export default function page() {
|
|||||||
|
|
||||||
const deleteMovies = () =>
|
const deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() => toast.success("Deleted all movies successfully!"))
|
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error("Failed to delete all movies");
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error("Failed to delete all TV-Series");
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const deleteAllMedia = async () =>
|
const deleteAllMedia = async () =>
|
||||||
await Promise.all([deleteMovies(), deleteShows()]);
|
await Promise.all([deleteMovies(), deleteShows()]);
|
||||||
@@ -96,11 +99,11 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<View className="py-4">
|
<View className="py-4">
|
||||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||||
{settings?.downloadMethod === "remux" && (
|
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
<Text className="text-lg font-bold">Queue</Text>
|
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
|
||||||
<Text className="text-xs opacity-70 text-red-600">
|
<Text className="text-xs opacity-70 text-red-600">
|
||||||
Queue and active downloads will be lost on app restart
|
{t("home.downloads.queue_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex flex-col space-y-2 mt-2">
|
<View className="flex flex-col space-y-2 mt-2">
|
||||||
{queue.map((q, index) => (
|
{queue.map((q, index) => (
|
||||||
@@ -133,7 +136,7 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{queue.length === 0 && (
|
{queue.length === 0 && (
|
||||||
<Text className="opacity-50">No items in queue</Text>
|
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -144,7 +147,7 @@ export default function page() {
|
|||||||
{movies.length > 0 && (
|
{movies.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
<Text className="text-lg font-bold">Movies</Text>
|
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<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>
|
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -163,7 +166,7 @@ export default function page() {
|
|||||||
{groupedBySeries.length > 0 && (
|
{groupedBySeries.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
<Text className="text-lg font-bold">TV-Series</Text>
|
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
<Text className="text-xs font-bold">
|
<Text className="text-xs font-bold">
|
||||||
{groupedBySeries?.length}
|
{groupedBySeries?.length}
|
||||||
@@ -189,7 +192,7 @@ export default function page() {
|
|||||||
)}
|
)}
|
||||||
{downloadedFiles?.length === 0 && (
|
{downloadedFiles?.length === 0 && (
|
||||||
<View className="flex px-4">
|
<View className="flex px-4">
|
||||||
<Text className="opacity-50">No downloaded items</Text>
|
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -214,13 +217,13 @@ export default function page() {
|
|||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className="p-4 space-y-4 mb-4">
|
<View className="p-4 space-y-4 mb-4">
|
||||||
<Button color="purple" onPress={deleteMovies}>
|
<Button color="purple" onPress={deleteMovies}>
|
||||||
Delete all Movies
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="purple" onPress={deleteShows}>
|
<Button color="purple" onPress={deleteShows}>
|
||||||
Delete all TV-Series
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="red" onPress={deleteAllMedia}>
|
<Button color="red" onPress={deleteAllMedia}>
|
||||||
Delete all
|
{t("home.downloads.delete_all_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
@@ -233,15 +236,15 @@ function migration_20241124() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { deleteAllFiles } = useDownload();
|
const { deleteAllFiles } = useDownload();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"New app version requires re-download",
|
t("home.downloads.new_app_version_requires_re_download"),
|
||||||
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
|
t("home.downloads.new_app_version_requires_re_download_description"),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: "Back",
|
text: t("home.downloads.back"),
|
||||||
onPress: () => router.back(),
|
onPress: () => router.back(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Delete",
|
text: t("home.downloads.delete"),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => await deleteAllFiles(),
|
onPress: async () => await deleteAllFiles(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,443 +1,5 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getSuggestionsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
getUserViewsApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
|
||||||
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
export default function page() {
|
||||||
type: "ScrollingCollectionList";
|
return <HomeIndex />;
|
||||||
title?: string;
|
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaListSection = {
|
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
|
||||||
|
|
||||||
export default function index() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [settings, _] = useSettings();
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
|
||||||
|
|
||||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className="p-2"
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name="download"
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={22}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [downloadedFiles, navigation, router]);
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
|
||||||
setLoadingRetry(true);
|
|
||||||
const state = await NetInfo.fetch();
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
setLoadingRetry(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
|
||||||
if (state.isConnected == false || state.isInternetReachable === false)
|
|
||||||
setIsConnected(false);
|
|
||||||
else setIsConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
NetInfo.fetch().then((state) => {
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
});
|
|
||||||
|
|
||||||
cleanCacheDirectory().catch((e) =>
|
|
||||||
console.error("Something went wrong cleaning cache directory")
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isError: e1,
|
|
||||||
isLoading: l1,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "userViews", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const userViews = useMemo(
|
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
|
||||||
[data, settings?.hiddenLibraries]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: mediaListCollections,
|
|
||||||
isError: e2,
|
|
||||||
isLoading: l2,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
tags: ["sf_promoted"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["Tags"],
|
|
||||||
includeItemTypes: ["BoxSet"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const collections = useMemo(() => {
|
|
||||||
const allow = ["movies", "tvshows"];
|
|
||||||
return (
|
|
||||||
userViews?.filter(
|
|
||||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [userViews]);
|
|
||||||
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
|
||||||
(
|
|
||||||
title: string,
|
|
||||||
queryKey: string[],
|
|
||||||
includeItemTypes: BaseItemKind[],
|
|
||||||
parentId: string | undefined
|
|
||||||
): ScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return [];
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 20,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || []
|
|
||||||
);
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
}),
|
|
||||||
[api, user?.Id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
|
||||||
const includeItemTypes: BaseItemKind[] =
|
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
|
||||||
const title = "Recently Added in " + c.Name;
|
|
||||||
const queryKey = [
|
|
||||||
"home",
|
|
||||||
"recentlyAddedIn" + c.CollectionType,
|
|
||||||
user?.Id!,
|
|
||||||
c.Id!,
|
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
{
|
|
||||||
title: "Continue Watching",
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Next Up",
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: 20,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
|
||||||
...(mediaListCollections?.map(
|
|
||||||
(ml) =>
|
|
||||||
({
|
|
||||||
title: ml.Name,
|
|
||||||
queryKey: ["home", "mediaList", ml.Id!],
|
|
||||||
queryFn: async () => ml,
|
|
||||||
type: "MediaListSection",
|
|
||||||
orientation: "vertical",
|
|
||||||
} as Section)
|
|
||||||
) || []),
|
|
||||||
{
|
|
||||||
title: "Suggested Movies",
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Suggested Episodes",
|
|
||||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
|
||||||
const nextUpPromises = suggestions.map((series) =>
|
|
||||||
getNextUp(api, user.Id, series.Id)
|
|
||||||
);
|
|
||||||
const nextUpResults = await Promise.all(nextUpPromises);
|
|
||||||
|
|
||||||
return nextUpResults.filter((item) => item !== null) || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, collections, mediaListCollections]);
|
|
||||||
|
|
||||||
if (isConnected === false) {
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
|
||||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
No worries, you can still watch{"\n"}downloaded content.
|
|
||||||
</Text>
|
|
||||||
<View className="mt-4">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify="center"
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Go to downloads
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="black"
|
|
||||||
onPress={() => {
|
|
||||||
checkConnection();
|
|
||||||
}}
|
|
||||||
justify="center"
|
|
||||||
className="mt-2"
|
|
||||||
iconRight={
|
|
||||||
loadingRetry ? null : (
|
|
||||||
<Ionicons name="refresh" size={20} color="white" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loadingRetry ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
"Retry"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e1 || e2)
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
|
||||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
Something went wrong.{"\n"}Please log out and in again.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1 || l2)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
|
||||||
}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col space-y-4">
|
|
||||||
<LargeMovieCarousel />
|
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
if (section.type === "ScrollingCollectionList") {
|
|
||||||
return (
|
|
||||||
<ScrollingCollectionList
|
|
||||||
key={index}
|
|
||||||
title={section.title}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
orientation={section.orientation}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (section.type === "MediaListSection") {
|
|
||||||
return (
|
|
||||||
<MediaListSection
|
|
||||||
key={index}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get suggestions
|
|
||||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
|
||||||
if (!userId) return [];
|
|
||||||
const response = await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Unknown"],
|
|
||||||
type: ["Series"],
|
|
||||||
});
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get the next up TV show for a series
|
|
||||||
async function getNextUp(
|
|
||||||
api: Api,
|
|
||||||
userId: string | undefined,
|
|
||||||
seriesId: string | undefined
|
|
||||||
) {
|
|
||||||
if (!userId || !seriesId) return null;
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
return response.data.Items?.[0] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { Feather, Ionicons } from "@expo/vector-icons";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -17,22 +19,21 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="bg-neutral-900 h-full py-32 px-4 space-y-4">
|
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-3xl font-bold text-center mb-2">
|
<Text className="text-3xl font-bold text-center mb-2">
|
||||||
Welcome to Streamyfin
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-center">
|
<Text className="text-center">
|
||||||
A free and open source client for Jellyfin.
|
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold">Features</Text>
|
<Text className="text-lg font-bold">
|
||||||
<Text className="text-xs">
|
{t("home.intro.features_title")}
|
||||||
Streamyfin has a bunch of features and integrates with a wide array of
|
|
||||||
software which you can find in the settings menu, these include:
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text className="text-xs">{t("home.intro.features_description")}</Text>
|
||||||
<View className="flex flex-row items-center mt-4">
|
<View className="flex flex-row items-center mt-4">
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||||
@@ -44,8 +45,7 @@ export default function page() {
|
|||||||
<View className="shrink ml-2">
|
<View className="shrink ml-2">
|
||||||
<Text className="font-bold mb-1">Jellyseerr</Text>
|
<Text className="font-bold mb-1">Jellyseerr</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className="shrink text-xs">
|
||||||
Connect to your Jellyseerr instance and request movies directly in
|
{t("home.intro.jellyseerr_feature_description")}
|
||||||
the app.
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -60,11 +60,11 @@ export default function page() {
|
|||||||
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
||||||
</View>
|
</View>
|
||||||
<View className="shrink ml-2">
|
<View className="shrink ml-2">
|
||||||
<Text className="font-bold mb-1">Downloads</Text>
|
<Text className="font-bold mb-1">
|
||||||
|
{t("home.intro.downloads_feature_title")}
|
||||||
|
</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className="shrink text-xs">
|
||||||
Download movies and tv-shows to view offline. Use either the
|
{t("home.intro.downloads_feature_description")}
|
||||||
default method or install the optimize server to download files in
|
|
||||||
the background.
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -81,29 +81,61 @@ export default function page() {
|
|||||||
<View className="shrink ml-2">
|
<View className="shrink ml-2">
|
||||||
<Text className="font-bold mb-1">Chromecast</Text>
|
<Text className="font-bold mb-1">Chromecast</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className="shrink text-xs">
|
||||||
Cast movies and tv-shows to your Chromecast devices.
|
{t("home.intro.chromecast_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center mt-4">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Feather name="settings" size={28} color={"white"} />
|
||||||
|
</View>
|
||||||
|
<View className="shrink ml-2">
|
||||||
|
<Text className="font-bold mb-1">
|
||||||
|
{t("home.intro.centralised_settings_plugin_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className="shrink text-xs">
|
||||||
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
|
<Text
|
||||||
|
className="text-purple-600"
|
||||||
|
onPress={() => {
|
||||||
|
Linking.openURL(
|
||||||
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.intro.read_more")}
|
||||||
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<View>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
Done
|
{t("home.intro.done_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.back();
|
router.back();
|
||||||
router.push("/settings");
|
router.push("/settings");
|
||||||
}}
|
}}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
<Text className="text-purple-600 text-center">Go to settings</Text>
|
<Text className="text-purple-600 text-center">
|
||||||
</TouchableOpacity>
|
{t("home.intro.go_to_settings_button")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
361
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
361
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
import {
|
||||||
|
Ionicons,
|
||||||
|
Entypo,
|
||||||
|
AntDesign,
|
||||||
|
MaterialCommunityIcons,
|
||||||
|
} from "@expo/vector-icons";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessions || sessions.length == 0)
|
||||||
|
return (
|
||||||
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
|
<Text className="text-lg text-neutral-500">
|
||||||
|
{t("home.sessions.no_active_sessions")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 17,
|
||||||
|
paddingHorizontal: 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
}}
|
||||||
|
data={sessions}
|
||||||
|
renderItem={({ item }) => <SessionCard session={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionCardProps {
|
||||||
|
session: SessionInfoDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionCard = ({ session }: SessionCardProps) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [remainingTicks, setRemainingTicks] = useState<number>(0);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (session.PlayState?.IsPaused) return;
|
||||||
|
setRemainingTicks(remainingTicks - 10000000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressPercentage = () => {
|
||||||
|
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(
|
||||||
|
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||||
|
(session.NowPlayingItem?.RunTimeTicks - remainingTicks)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTime = session.PlayState?.PositionTicks;
|
||||||
|
const duration = session.NowPlayingItem?.RunTimeTicks;
|
||||||
|
if (
|
||||||
|
duration !== null &&
|
||||||
|
duration !== undefined &&
|
||||||
|
currentTime !== null &&
|
||||||
|
currentTime !== undefined
|
||||||
|
) {
|
||||||
|
const remainingTimeTicks = duration - currentTime;
|
||||||
|
setRemainingTicks(remainingTimeTicks);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
useInterval(tick, 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4">
|
||||||
|
<View className="flex flex-row p-4">
|
||||||
|
<View className="w-20 pr-4">
|
||||||
|
<Poster
|
||||||
|
id={session.NowPlayingItem?.Id}
|
||||||
|
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="w-full flex-1">
|
||||||
|
<View className="flex flex-row justify-between">
|
||||||
|
<View className="flex-1 pr-4">
|
||||||
|
{session.NowPlayingItem?.Type === "Episode" ? (
|
||||||
|
<>
|
||||||
|
<Text className="font-bold">
|
||||||
|
{session.NowPlayingItem?.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
|
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
||||||
|
{" - "}
|
||||||
|
{session.NowPlayingItem.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="font-bold">
|
||||||
|
{session.NowPlayingItem?.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{session.NowPlayingItem?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{session.NowPlayingItem?.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className="text-xs opacity-50 align-right text-right">
|
||||||
|
{session.UserName}
|
||||||
|
{"\n"}
|
||||||
|
{session.Client}
|
||||||
|
{"\n"}
|
||||||
|
{session.DeviceName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1" />
|
||||||
|
<View className="flex flex-col align-bottom">
|
||||||
|
<View className="flex flex-row justify-between align-bottom mb-1">
|
||||||
|
<Text className="-ml-0.5 text-xs opacity-50 align-left text-left">
|
||||||
|
{!session.PlayState?.IsPaused ? (
|
||||||
|
<Ionicons name="play" size={14} color="white" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="pause" size={14} color="white" />
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50 align-right text-right">
|
||||||
|
{formatTimeString(remainingTicks, "tick")} left
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="align-bottom bg-gray-800 h-1">
|
||||||
|
<View
|
||||||
|
className={`bg-purple-600 h-full`}
|
||||||
|
style={{
|
||||||
|
width: `${getProgressPercentage()}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TranscodingView session={session} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TranscodingBadgesProps {
|
||||||
|
properties: StreamProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||||
|
const iconMap = {
|
||||||
|
bitrate: <Ionicons name="speedometer-outline" size={12} color="white" />,
|
||||||
|
codec: <Ionicons name="layers-outline" size={12} color="white" />,
|
||||||
|
videoRange: (
|
||||||
|
<Ionicons name="color-palette-outline" size={12} color="white" />
|
||||||
|
),
|
||||||
|
resolution: <Ionicons name="film-outline" size={12} color="white" />,
|
||||||
|
language: <Ionicons name="language-outline" size={12} color="white" />,
|
||||||
|
audioChannels: <Ionicons name="mic-outline" size={12} color="white" />,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const icon = (val: string) => {
|
||||||
|
return (
|
||||||
|
iconMap[val as keyof typeof iconMap] ?? (
|
||||||
|
<Ionicons name="layers-outline" size={12} color="white" />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatVal = (key: string, val: any) => {
|
||||||
|
switch (key) {
|
||||||
|
case "bitrate":
|
||||||
|
return formatBitrate(val);
|
||||||
|
default:
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.entries(properties)
|
||||||
|
.filter(([_, value]) => value !== undefined && value !== null)
|
||||||
|
.map(([key]) => (
|
||||||
|
<Badge
|
||||||
|
key={key}
|
||||||
|
variant="gray"
|
||||||
|
className="m-0 p-0 pt-0.5 mr-1"
|
||||||
|
text={formatVal(key, properties[key as keyof StreamProps])}
|
||||||
|
iconLeft={icon(key)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StreamProps {
|
||||||
|
resolution?: string | null | undefined;
|
||||||
|
language?: string | null | undefined;
|
||||||
|
codec?: string | null | undefined;
|
||||||
|
bitrate?: number | null | undefined;
|
||||||
|
videoRange?: string | null | undefined;
|
||||||
|
audioChannels?: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscodingStreamViewProps {
|
||||||
|
title: string | undefined;
|
||||||
|
value?: string;
|
||||||
|
isTranscoding: Boolean;
|
||||||
|
transcodeValue?: string | undefined | null;
|
||||||
|
properties: StreamProps;
|
||||||
|
transcodeProperties?: StreamProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingStreamView = ({
|
||||||
|
title,
|
||||||
|
isTranscoding,
|
||||||
|
properties,
|
||||||
|
transcodeProperties,
|
||||||
|
value,
|
||||||
|
transcodeValue,
|
||||||
|
}: TranscodingStreamViewProps) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col pt-2 first:pt-0">
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<Text className="text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className="flex-1">
|
||||||
|
<TranscodingBadges properties={properties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isTranscoding && transcodeProperties ? (
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<Text className="-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="arrow-right-bottom"
|
||||||
|
size={14}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<Text className="flex-1 text-sm mt-1">
|
||||||
|
<TranscodingBadges properties={transcodeProperties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||||
|
const videoStream = useMemo(() => {
|
||||||
|
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type == "Video"
|
||||||
|
)[0];
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const audioStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.AudioStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.AudioStreamIndex]);
|
||||||
|
|
||||||
|
const subtitleStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.SubtitleStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||||
|
|
||||||
|
const isTranscoding = useMemo(() => {
|
||||||
|
return session.PlayState?.PlayMethod == "Transcode";
|
||||||
|
}, [session.PlayState?.PlayMethod]);
|
||||||
|
|
||||||
|
const videoStreamTitle = () => {
|
||||||
|
return videoStream?.DisplayTitle?.split(" ")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2">
|
||||||
|
<TranscodingStreamView
|
||||||
|
title="Video"
|
||||||
|
properties={{
|
||||||
|
resolution: videoStreamTitle(),
|
||||||
|
bitrate: videoStream?.BitRate,
|
||||||
|
codec: videoStream?.Codec,
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
|
codec: session.TranscodingInfo?.VideoCodec,
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TranscodingStreamView
|
||||||
|
title="Audio"
|
||||||
|
properties={{
|
||||||
|
language: audioStream?.Language,
|
||||||
|
bitrate: audioStream?.BitRate,
|
||||||
|
codec: audioStream?.Codec,
|
||||||
|
audioChannels: audioStream?.ChannelLayout,
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
|
codec: session.TranscodingInfo?.AudioCodec,
|
||||||
|
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{subtitleStream && (
|
||||||
|
<>
|
||||||
|
<TranscodingStreamView
|
||||||
|
title="Subtitle"
|
||||||
|
isTranscoding={false}
|
||||||
|
properties={{
|
||||||
|
language: subtitleStream?.Language,
|
||||||
|
codec: subtitleStream?.Codec,
|
||||||
|
}}
|
||||||
|
transcodeValue={null}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
import { DownloadSettings } from "@/components/settings/DownloadSettings";
|
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
@@ -11,18 +12,23 @@ import { QuickConnect } from "@/components/settings/QuickConnect";
|
|||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
@@ -40,7 +46,9 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-red-600">Log out</Text>
|
<Text className="text-red-600">
|
||||||
|
{t("home.settings.log_out_button")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -55,6 +63,7 @@ export default function settings() {
|
|||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
|
||||||
<QuickConnect className="mb-4" />
|
<QuickConnect className="mb-4" />
|
||||||
|
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
@@ -64,37 +73,42 @@ export default function settings() {
|
|||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
|
|
||||||
<DownloadSettings />
|
<DownloadSettings />
|
||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
|
<AppLanguageSelector />
|
||||||
|
|
||||||
|
<ChromecastSettings />
|
||||||
|
|
||||||
<ListGroup title={"Intro"}>
|
<ListGroup title={"Intro"}>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/intro/page");
|
router.push("/intro/page");
|
||||||
}}
|
}}
|
||||||
title={"Show intro"}
|
title={t("home.settings.intro.show_intro")}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor="red"
|
textColor="red"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
storage.set("hasShownIntro", false);
|
storage.set("hasShownIntro", false);
|
||||||
}}
|
}}
|
||||||
title={"Reset intro"}
|
title={t("home.settings.intro.reset_intro")}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<ListGroup title={"Logs"}>
|
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
showArrow
|
showArrow
|
||||||
title={"Logs"}
|
title={t("home.settings.logs.logs_title")}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor="red"
|
textColor="red"
|
||||||
onPress={onClearLogsClicked}
|
onPress={onClearLogsClicked}
|
||||||
title={"Delete All Logs"}
|
title={t("home.settings.logs.delete_all_logs")}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Switch, View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, isLoading: isLoading } = useQuery({
|
const { data, isLoading: isLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -35,7 +39,10 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="px-4">
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
@@ -53,9 +60,8 @@ export default function page() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
Select the libraries you want to hide from the Library tab and home page
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
sections.
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,16 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: async (newVal: string) => {
|
|
||||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
|
||||||
toast.error("Invalid URL");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
optimizedVersionsServerUrl: updatedUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await getStatistics({
|
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
|
||||||
authHeader: api?.accessToken,
|
|
||||||
deviceId: getOrSetDeviceId(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data) {
|
|
||||||
toast.success("Connected");
|
|
||||||
} else {
|
|
||||||
toast.error("Could not connect");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Could not connect");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSave = (newVal: string) => {
|
|
||||||
saveMutation.mutate(newVal);
|
|
||||||
};
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// navigation.setOptions({
|
|
||||||
// title: "Optimized Server",
|
|
||||||
// headerRight: () =>
|
|
||||||
// saveMutation.isPending ? (
|
|
||||||
// <ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
// ) : (
|
|
||||||
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
|
||||||
// <Text className="text-blue-500">Save</Text>
|
|
||||||
// </TouchableOpacity>
|
|
||||||
// ),
|
|
||||||
// });
|
|
||||||
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="p-4">
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className="p-4"
|
||||||
|
>
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useLog } from "@/utils/log";
|
import { useLog } from "@/utils/log";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="p-4">
|
<ScrollView className="p-4">
|
||||||
@@ -25,7 +27,7 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
{logs?.length === 0 && (
|
{logs?.length === 0 && (
|
||||||
<Text className="opacity-50">No logs available</Text>
|
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
import React, {useEffect, useMemo, useState} from "react";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -15,11 +15,14 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const [settings, updateSettings] = useSettings();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
@@ -28,76 +31,87 @@ export default function page() {
|
|||||||
updateSettings({
|
updateSettings({
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
});
|
});
|
||||||
toast.success("Saved");
|
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
const handleOpenLink = () => {
|
||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
headerRight: () => (
|
navigation.setOptions({
|
||||||
<TouchableOpacity onPress={() => onSave(value)}>
|
headerRight: () => (
|
||||||
<Text className="text-blue-500">Save</Text>
|
<TouchableOpacity onPress={() => onSave(value)}>
|
||||||
</TouchableOpacity>
|
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
|
||||||
),
|
</TouchableOpacity>
|
||||||
});
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [navigation, value]);
|
}, [navigation, value]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="px-4">
|
<DisabledSetting
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<ListItem
|
<DisabledSetting
|
||||||
title={"Enable Marlin Search"}
|
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||||
onPress={() => {
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<ListItem
|
||||||
value={settings.searchEngine === "Marlin"}
|
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
|
||||||
onValueChange={(value) => {
|
onPress={() => {
|
||||||
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</ListItem>
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<View
|
<DisabledSetting
|
||||||
className={`mt-2 ${
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
settings.searchEngine === "Marlin" ? "" : "opacity-50"
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
}`}
|
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
|
||||||
>
|
>
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
|
<View
|
||||||
<View
|
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
||||||
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
>
|
||||||
>
|
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
|
||||||
<Text className="mr-4">URL</Text>
|
<TextInput
|
||||||
<TextInput
|
editable={settings.searchEngine === "Marlin"}
|
||||||
editable={settings.searchEngine === "Marlin"}
|
className="text-white"
|
||||||
className="text-white"
|
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
|
||||||
placeholder="http(s)://domain.org:port"
|
value={value}
|
||||||
value={value}
|
keyboardType="url"
|
||||||
keyboardType="url"
|
returnKeyType="done"
|
||||||
returnKeyType="done"
|
autoCapitalize="none"
|
||||||
autoCapitalize="none"
|
textContentType="URL"
|
||||||
textContentType="URL"
|
onChangeText={(text) => setValue(text)}
|
||||||
onChangeText={(text) => setValue(text)}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
</DisabledSetting>
|
||||||
Enter the URL for the Marlin server. The URL should include http or
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
https and optionally the port.{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
Read more about Marlin.
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</Text>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ import { useAtom } from "jotai";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||||
@@ -23,7 +27,7 @@ export default function page() {
|
|||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (newVal: string) => {
|
mutationFn: async (newVal: string) => {
|
||||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||||
toast.error("Invalid URL");
|
toast.error(t("home.settings.toasts.invalid_url"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,13 +45,13 @@ export default function page() {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data) {
|
if (data) {
|
||||||
toast.success("Connected");
|
toast.success(t("home.settings.toasts.connected"));
|
||||||
} else {
|
} else {
|
||||||
toast.error("Could not connect");
|
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("Could not connect");
|
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,25 +60,30 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
|
||||||
title: "Optimized Server",
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
title: t("home.settings.downloads.optimized_server"),
|
||||||
saveMutation.isPending ? (
|
headerRight: () =>
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
saveMutation.isPending ? (
|
||||||
) : (
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
) : (
|
||||||
<Text className="text-blue-500">Save</Text>
|
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
||||||
</TouchableOpacity>
|
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
|
||||||
),
|
</TouchableOpacity>
|
||||||
});
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="p-4">
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||||
|
className="p-4"
|
||||||
|
>
|
||||||
<OptimizedServerForm
|
<OptimizedServerForm
|
||||||
value={optimizedVersionsServerUrl}
|
value={optimizedVersionsServerUrl}
|
||||||
onChangeValue={setOptimizedVersionsServerUrl}
|
onChangeValue={setOptimizedVersionsServerUrl}
|
||||||
/>
|
/>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { Linking, Switch, View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
|
||||||
Linking.openURL(
|
|
||||||
"https://github.com/lostb1t/jellyfin-plugin-collection-import"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: mediaListCollections,
|
|
||||||
isLoading: isLoadingMediaListCollections,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
tags: ["sf_promoted"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["Tags"],
|
|
||||||
includeItemTypes: ["BoxSet"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="px-4 pt-4">
|
|
||||||
<ListGroup title={"Enable plugin"} className="">
|
|
||||||
<ListItem
|
|
||||||
title={"Enable Popular Lists"}
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({ usePopularPlugin: true });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.usePopularPlugin}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({ usePopularPlugin: value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
|
||||||
Popular Lists is a plugin that enables you to show custom Jellyfin lists
|
|
||||||
on the Streamyfin home page.{" "}
|
|
||||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
|
||||||
Read more about Popular Lists.
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{settings.usePopularPlugin && (
|
|
||||||
<>
|
|
||||||
{!isLoadingMediaListCollections ? (
|
|
||||||
<>
|
|
||||||
{mediaListCollections?.length === 0 ? (
|
|
||||||
<Text className="text-xs opacity-50 p-4">
|
|
||||||
No collections found. Add some in Jellyfin.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ListGroup title="Media List Collections" className="mt-4">
|
|
||||||
{mediaListCollections?.map((mlc) => (
|
|
||||||
<ListItem key={mlc.Id} title={mlc.Name}>
|
|
||||||
<Switch
|
|
||||||
value={settings.mediaListCollectionIds?.includes(
|
|
||||||
mlc.Id!
|
|
||||||
)}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (!settings.mediaListCollectionIds) {
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds: [mlc.Id!],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds:
|
|
||||||
settings.mediaListCollectionIds.includes(
|
|
||||||
mlc.Id!
|
|
||||||
)
|
|
||||||
? settings.mediaListCollectionIds.filter(
|
|
||||||
(id) => id !== mlc.Id
|
|
||||||
)
|
|
||||||
: [
|
|
||||||
...settings.mediaListCollectionIds,
|
|
||||||
mlc.Id!,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</ListGroup>
|
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
|
||||||
Select the lists you want displayed on the home screen.
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Loader />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,10 +18,12 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { actorId } = local as { actorId: string };
|
const { actorId } = local as { actorId: string };
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -110,7 +112,7 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||||
Appeared In
|
{t("item_card.appeared_in")}
|
||||||
</Text>
|
</Text>
|
||||||
<InfiniteHorizontalScroll
|
<InfiniteHorizontalScroll
|
||||||
height={247}
|
height={247}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { SongsList } from "@/components/music/SongsList";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { collectionId, artistId, albumId } = searchParams as {
|
|
||||||
collectionId: string;
|
|
||||||
artistId: string;
|
|
||||||
albumId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: album } = useQuery({
|
|
||||||
queryKey: ["album", albumId, artistId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
ids: [albumId],
|
|
||||||
});
|
|
||||||
const data = response.data.Items?.[0];
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!albumId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: songs,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery<{
|
|
||||||
Items: BaseItemDto[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}>({
|
|
||||||
queryKey: ["songs", artistId, albumId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api)
|
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: albumId,
|
|
||||||
fields: [
|
|
||||||
"ItemCounts",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
"CanDelete",
|
|
||||||
"MediaSourceCount",
|
|
||||||
],
|
|
||||||
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data.Items;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (!album) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerHeight={400}
|
|
||||||
headerImage={
|
|
||||||
<ItemImage
|
|
||||||
variant={"Primary"}
|
|
||||||
item={album}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="px-4 mb-8">
|
|
||||||
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
|
|
||||||
<Text className="text-neutral-500">
|
|
||||||
{songs?.TotalRecordCount} songs
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="px-4">
|
|
||||||
<SongsList
|
|
||||||
albumId={albumId}
|
|
||||||
songs={songs?.Items}
|
|
||||||
collectionId={collectionId}
|
|
||||||
artistId={artistId}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { artistId } = searchParams as {
|
|
||||||
artistId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const { data: artist } = useQuery({
|
|
||||||
queryKey: ["album", artistId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
ids: [artistId],
|
|
||||||
});
|
|
||||||
const data = response.data.Items?.[0];
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!artistId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: albums,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery<{
|
|
||||||
Items: BaseItemDto[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}>({
|
|
||||||
queryKey: ["albums", artistId, startIndex],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api)
|
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: artistId,
|
|
||||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
|
||||||
includeItemTypes: ["MusicAlbum"],
|
|
||||||
recursive: true,
|
|
||||||
fields: [
|
|
||||||
"ParentId",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
"ParentId",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
],
|
|
||||||
collapseBoxSetItems: false,
|
|
||||||
albumArtistIds: [artistId],
|
|
||||||
startIndex,
|
|
||||||
limit: 100,
|
|
||||||
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data.Items;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (!artist || !albums) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerHeight={400}
|
|
||||||
headerImage={
|
|
||||||
<ItemImage
|
|
||||||
variant={"Primary"}
|
|
||||||
item={artist}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="px-4 mb-8">
|
|
||||||
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
|
|
||||||
<Text className="text-neutral-500">
|
|
||||||
{albums.TotalRecordCount} albums
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row flex-wrap justify-between px-4">
|
|
||||||
{albums.Items.map((item, idx) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
style={{ width: "30%", marginBottom: 20 }}
|
|
||||||
key={idx}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col gap-y-2">
|
|
||||||
<ArtistPoster item={item} />
|
|
||||||
<Text numberOfLines={2}>{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
|
||||||
queryKey: ["collection", collectionId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
ids: [collectionId],
|
|
||||||
});
|
|
||||||
const data = response.data.Items?.[0];
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!collectionId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery<{
|
|
||||||
Items: BaseItemDto[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}>({
|
|
||||||
queryKey: ["collection-items", collection?.Id, startIndex],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !collectionId)
|
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await getArtistsApi(api).getArtists({
|
|
||||||
sortBy: ["SortName"],
|
|
||||||
sortOrder: ["Ascending"],
|
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
|
||||||
parentId: collectionId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data.Items;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!collection?.Id && !!api && !!user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalItems = useMemo(() => {
|
|
||||||
return data?.TotalRecordCount;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlatList
|
|
||||||
contentContainerStyle={{
|
|
||||||
padding: 16,
|
|
||||||
paddingBottom: 140,
|
|
||||||
}}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<View className="mb-4">
|
|
||||||
<Text className="font-bold text-3xl mb-2">Artists</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
nestedScrollEnabled
|
|
||||||
data={data.Items}
|
|
||||||
numColumns={3}
|
|
||||||
columnWrapperStyle={{
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
style={{
|
|
||||||
maxWidth: "30%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
key={index}
|
|
||||||
item={item}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col gap-y-2">
|
|
||||||
{collection?.CollectionType === "movies" && (
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
)}
|
|
||||||
{collection?.CollectionType === "music" && (
|
|
||||||
<ArtistPoster item={item} />
|
|
||||||
)}
|
|
||||||
<Text>{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id || ""}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -29,10 +29,11 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -45,6 +46,8 @@ const page: React.FC = () => {
|
|||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
@@ -109,7 +112,7 @@ const page: React.FC = () => {
|
|||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
|
includeItemTypes: ["Movie", "Series"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -244,7 +247,7 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedGenres}
|
set={setSelectedGenres}
|
||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title="Genres"
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -271,7 +274,7 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedYears}
|
set={setSelectedYears}
|
||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title="Years"
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
/>
|
/>
|
||||||
@@ -296,7 +299,7 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedTags}
|
set={setSelectedTags}
|
||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title="Tags"
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -314,7 +317,7 @@ const page: React.FC = () => {
|
|||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
title="Sort By"
|
title={t("library.filters.sort_by")}
|
||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
@@ -334,7 +337,7 @@ const page: React.FC = () => {
|
|||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
title="Sort Order"
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
@@ -374,7 +377,7 @@ const page: React.FC = () => {
|
|||||||
<FlashList
|
<FlashList
|
||||||
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">{t("search.no_results")}</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
extraData={[
|
extraData={[
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: item, isError } = useQuery({
|
const { data: item, isError } = useQuery({
|
||||||
queryKey: ["item", id],
|
queryKey: ["item", id],
|
||||||
@@ -74,7 +76,7 @@ const Page: React.FC = () => {
|
|||||||
if (isError)
|
if (isError)
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
||||||
<Text>Could not load item</Text>
|
<Text>{t("item_card.could_not_load_item")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
import {
|
|
||||||
router,
|
|
||||||
useLocalSearchParams,
|
|
||||||
useNavigation,
|
|
||||||
useSegments,
|
|
||||||
} from "expo-router";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Animated } from "react-native";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { orderBy } from "lodash";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
|
||||||
import Poster from "@/components/posters/Poster";
|
|
||||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
|
||||||
|
|
||||||
const ANIMATION_ENTER = 250;
|
|
||||||
const ANIMATION_EXIT = 250;
|
|
||||||
const BACKDROP_DURATION = 5000;
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const segments = useSegments();
|
|
||||||
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
|
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
|
||||||
const from = segments[2];
|
|
||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useQuery({
|
|
||||||
queryKey: ["jellyseerr", "person", personId],
|
|
||||||
queryFn: async () => ({
|
|
||||||
details: await jellyseerrApi?.personDetails(personId),
|
|
||||||
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
|
|
||||||
}),
|
|
||||||
enabled: !!jellyseerrApi && !!personId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const locale = useMemo(() => {
|
|
||||||
return jellyseerrUser?.settings?.locale || "en";
|
|
||||||
}, [jellyseerrUser]);
|
|
||||||
|
|
||||||
const region = useMemo(
|
|
||||||
() => jellyseerrUser?.settings?.region || "US",
|
|
||||||
[jellyseerrUser]
|
|
||||||
);
|
|
||||||
|
|
||||||
const castedRoles: PersonCreditCast[] = useMemo(
|
|
||||||
() =>
|
|
||||||
orderBy(
|
|
||||||
data?.combinedCredits?.cast,
|
|
||||||
["voteCount", "voteAverage"],
|
|
||||||
"desc"
|
|
||||||
),
|
|
||||||
[data?.combinedCredits]
|
|
||||||
);
|
|
||||||
|
|
||||||
const backdrops = useMemo(
|
|
||||||
() => castedRoles.map((c) => c.backdropPath),
|
|
||||||
[data?.combinedCredits]
|
|
||||||
);
|
|
||||||
|
|
||||||
const enterAnimation = useCallback(
|
|
||||||
() =>
|
|
||||||
Animated.timing(fadeAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: ANIMATION_ENTER,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
[fadeAnim]
|
|
||||||
);
|
|
||||||
|
|
||||||
const exitAnimation = useCallback(
|
|
||||||
() =>
|
|
||||||
Animated.timing(fadeAnim, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: ANIMATION_EXIT,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
[fadeAnim]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (backdrops?.length) {
|
|
||||||
enterAnimation().start();
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
exitAnimation().start((end) => {
|
|
||||||
if (end.finished)
|
|
||||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % backdrops?.length);
|
|
||||||
});
|
|
||||||
}, BACKDROP_DURATION);
|
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
}, [backdrops, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
|
|
||||||
|
|
||||||
const viewDetails = (credit: PersonCreditCast) => {
|
|
||||||
router.push({
|
|
||||||
//@ts-ignore
|
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
|
||||||
//@ts-ignore
|
|
||||||
params: {
|
|
||||||
...credit,
|
|
||||||
mediaTitle: credit.title,
|
|
||||||
releaseYear: new Date(credit.releaseDate).getFullYear(),
|
|
||||||
canRequest: "false",
|
|
||||||
posterSrc: jellyseerrApi?.imageProxy(
|
|
||||||
credit.posterPath,
|
|
||||||
"w300_and_h450_face"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex-1 relative"
|
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParallaxScrollView
|
|
||||||
className="flex-1 opacity-100"
|
|
||||||
headerHeight={300}
|
|
||||||
headerImage={
|
|
||||||
<Animated.Image
|
|
||||||
source={{
|
|
||||||
uri: jellyseerrApi?.imageProxy(
|
|
||||||
backdrops?.[currentIndex],
|
|
||||||
"w1920_and_h800_multi_faces"
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
opacity: fadeAnim,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
logo={
|
|
||||||
<Image
|
|
||||||
key={data?.details?.id}
|
|
||||||
id={data?.details?.id.toString()}
|
|
||||||
className="rounded-full bottom-1"
|
|
||||||
source={{
|
|
||||||
uri: jellyseerrApi?.imageProxy(
|
|
||||||
data?.details?.profilePath,
|
|
||||||
"w600_and_h600_bestv2"
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit="cover"
|
|
||||||
style={{
|
|
||||||
width: 125,
|
|
||||||
height: 125,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col space-y-4 px-4">
|
|
||||||
<View className="flex flex-row justify-between w-full">
|
|
||||||
<View className="flex flex-col w-full">
|
|
||||||
<Text className="font-bold text-2xl mb-1">
|
|
||||||
{data?.details?.name}
|
|
||||||
</Text>
|
|
||||||
<Text className="opacity-50">
|
|
||||||
Born{" "}
|
|
||||||
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
|
||||||
`${locale}-${region}`,
|
|
||||||
{
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
}
|
|
||||||
)}{" "}
|
|
||||||
| {data?.details?.placeOfBirth}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<OverviewText text={data?.details?.biography} className="mt-4" />
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<FlashList
|
|
||||||
data={castedRoles}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
|
||||||
<Text className="font-bold text-xl text-neutral-500">
|
|
||||||
No results
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
ListHeaderComponent={
|
|
||||||
<Text className="text-lg font-bold my-2">Appearances</Text>
|
|
||||||
}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-full flex flex-col pr-2"
|
|
||||||
onPress={() => viewDetails(item)}
|
|
||||||
>
|
|
||||||
<Poster
|
|
||||||
id={item.id.toString()}
|
|
||||||
url={jellyseerrApi?.imageProxy(item.posterPath)}
|
|
||||||
/>
|
|
||||||
<JellyseerrMediaIcon
|
|
||||||
className="absolute top-1 left-1"
|
|
||||||
mediaType={item.mediaType as "movie" | "tv"}
|
|
||||||
/>
|
|
||||||
{/*<Text numberOfLines={1}>{item.title}</Text>*/}
|
|
||||||
{item.character && (
|
|
||||||
<Text
|
|
||||||
className="text-xs opacity-50 align-bottom mt-1"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
as {item.character}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.id.toString()}
|
|
||||||
estimatedItemSize={255}
|
|
||||||
numColumns={3}
|
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
|
||||||
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {router, useLocalSearchParams, useSegments,} from "expo-router";
|
||||||
|
import React, {useMemo,} from "react";
|
||||||
|
import {TouchableOpacity} from "react-native";
|
||||||
|
import {useInfiniteQuery} from "@tanstack/react-query";
|
||||||
|
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
import {Image} from "expo-image";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||||
|
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const {jellyseerrApi} = useJellyseerr();
|
||||||
|
|
||||||
|
const {companyId, name, image, type} = local as unknown as {
|
||||||
|
companyId: string,
|
||||||
|
name: string,
|
||||||
|
image: string,
|
||||||
|
type: DiscoverSliderType
|
||||||
|
};
|
||||||
|
|
||||||
|
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
||||||
|
queryKey: ["jellyseerr", "company", type, companyId],
|
||||||
|
queryFn: async ({pageParam}) => {
|
||||||
|
let params: any = {
|
||||||
|
page: Number(pageParam),
|
||||||
|
};
|
||||||
|
|
||||||
|
return jellyseerrApi?.discover(
|
||||||
|
(
|
||||||
|
type == DiscoverSliderType.NETWORKS
|
||||||
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
|
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||||
|
) + `/${companyId}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled: !!jellyseerrApi && !!companyId,
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, pages) =>
|
||||||
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
|
1,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(
|
||||||
|
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const backdrops = useMemo(
|
||||||
|
() => jellyseerrApi
|
||||||
|
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
||||||
|
: [],
|
||||||
|
[jellyseerrApi, flatData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParallaxSlideShow
|
||||||
|
data={flatData}
|
||||||
|
images={backdrops}
|
||||||
|
listHeader=""
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
logo={
|
||||||
|
<Image
|
||||||
|
id={companyId}
|
||||||
|
key={companyId}
|
||||||
|
className="bottom-1 w-1/2"
|
||||||
|
source={{
|
||||||
|
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="contain"
|
||||||
|
style={{
|
||||||
|
aspectRatio: "4/3",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={(item, index) =>
|
||||||
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {router, useLocalSearchParams, useSegments,} from "expo-router";
|
||||||
|
import React, {useMemo,} from "react";
|
||||||
|
import {TouchableOpacity} from "react-native";
|
||||||
|
import {useInfiniteQuery} from "@tanstack/react-query";
|
||||||
|
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||||
|
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const {jellyseerrApi} = useJellyseerr();
|
||||||
|
|
||||||
|
const {genreId, name, type} = local as unknown as {
|
||||||
|
genreId: string,
|
||||||
|
name: string,
|
||||||
|
type: DiscoverSliderType
|
||||||
|
};
|
||||||
|
|
||||||
|
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
||||||
|
queryKey: ["jellyseerr", "company", type, genreId],
|
||||||
|
queryFn: async ({pageParam}) => {
|
||||||
|
let params: any = {
|
||||||
|
page: Number(pageParam),
|
||||||
|
genre: genreId
|
||||||
|
};
|
||||||
|
|
||||||
|
return jellyseerrApi?.discover(
|
||||||
|
type == DiscoverSliderType.MOVIE_GENRES
|
||||||
|
? Endpoints.DISCOVER_MOVIES
|
||||||
|
: Endpoints.DISCOVER_TV,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled: !!jellyseerrApi && !!genreId,
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, pages) =>
|
||||||
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
|
1,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(
|
||||||
|
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const backdrops = useMemo(
|
||||||
|
() => jellyseerrApi
|
||||||
|
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
||||||
|
: [],
|
||||||
|
[jellyseerrApi, flatData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParallaxSlideShow
|
||||||
|
data={flatData}
|
||||||
|
images={backdrops}
|
||||||
|
listHeader=""
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
logo={
|
||||||
|
<Text
|
||||||
|
className="text-4xl font-bold text-center bottom-1"
|
||||||
|
style={{
|
||||||
|
...textShadowStyle.shadow,
|
||||||
|
shadowRadius: 10
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={(item, index) =>
|
||||||
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,24 @@
|
|||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
|
||||||
import {
|
|
||||||
MediaRequestStatus,
|
|
||||||
MediaStatus,
|
|
||||||
MediaType,
|
|
||||||
} from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
|
import {
|
||||||
|
IssueType,
|
||||||
|
IssueTypeName,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackdropProps,
|
BottomSheetBackdropProps,
|
||||||
@@ -30,23 +26,28 @@ import {
|
|||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import {
|
import { useQuery } from "@tanstack/react-query";
|
||||||
IssueType,
|
import { Image } from "expo-image";
|
||||||
IssueTypeName,
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
import React, {
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
useCallback,
|
||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
useEffect,
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
useMemo,
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
useRef,
|
||||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
useState,
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
} from "react";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import Cast from "@/components/jellyseerr/Cast";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
|
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { mediaTitle, releaseYear, posterSrc, ...result } =
|
const { mediaTitle, releaseYear, posterSrc, ...result } =
|
||||||
params as unknown as {
|
params as unknown as {
|
||||||
mediaTitle: string;
|
mediaTitle: string;
|
||||||
@@ -60,6 +61,7 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -83,7 +85,8 @@ const Page: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const canRequest = useJellyseerrCanRequest(details);
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -109,19 +112,29 @@ const Page: React.FC = () => {
|
|||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const request = useCallback(async () => {
|
const request = useCallback(async () => {
|
||||||
requestMedia(
|
const body: MediaRequestBody = {
|
||||||
mediaTitle,
|
mediaId: Number(result.id!!),
|
||||||
{
|
mediaType: result.mediaType!!,
|
||||||
mediaId: Number(result.id!!),
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
mediaType: result.mediaType!!,
|
seasons: (details as TvDetails)?.seasons
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
seasons: (details as TvDetails)?.seasons
|
?.map?.((s) => s.seasonNumber),
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
};
|
||||||
?.map?.((s) => s.seasonNumber),
|
|
||||||
},
|
if (hasAdvancedRequestPermission) {
|
||||||
refetch
|
advancedReqModalRef?.current?.present?.(body);
|
||||||
);
|
return;
|
||||||
}, [details, result, requestMedia]);
|
}
|
||||||
|
|
||||||
|
requestMedia(mediaTitle, body, refetch);
|
||||||
|
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
|
||||||
|
|
||||||
|
const isAnime = useMemo(
|
||||||
|
() =>
|
||||||
|
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
||||||
|
result.mediaType === MediaType.TV,
|
||||||
|
[details]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
@@ -213,7 +226,7 @@ const Page: React.FC = () => {
|
|||||||
<Button loading={true} disabled={true} color="purple"></Button>
|
<Button loading={true} disabled={true} color="purple"></Button>
|
||||||
) : canRequest ? (
|
) : canRequest ? (
|
||||||
<Button color="purple" onPress={request}>
|
<Button color="purple" onPress={request}>
|
||||||
Request
|
{t("jellyseerr.request_button")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -228,7 +241,7 @@ const Page: React.FC = () => {
|
|||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Report issue
|
{t("jellyseerr.report_issue_button")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<OverviewText text={result.overview} className="mt-4" />
|
<OverviewText text={result.overview} className="mt-4" />
|
||||||
@@ -240,6 +253,10 @@ const Page: React.FC = () => {
|
|||||||
result={result as TvResult}
|
result={result as TvResult}
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
|
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||||
|
onAdvancedRequest={(data) =>
|
||||||
|
advancedReqModalRef?.current?.present(data)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailFacts
|
<DetailFacts
|
||||||
@@ -250,6 +267,17 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
|
<RequestModal
|
||||||
|
ref={advancedReqModalRef}
|
||||||
|
title={mediaTitle}
|
||||||
|
id={result.id!!}
|
||||||
|
type={result.mediaType as MediaType}
|
||||||
|
isAnime={isAnime}
|
||||||
|
onRequested={() => {
|
||||||
|
advancedReqModalRef?.current?.close();
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
@@ -265,7 +293,7 @@ const Page: React.FC = () => {
|
|||||||
<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">
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-2xl text-neutral-100">
|
<Text className="font-bold text-2xl text-neutral-100">
|
||||||
Whats wrong?
|
{t("jellyseerr.whats_wrong")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col space-y-2 items-start">
|
<View className="flex flex-col space-y-2 items-start">
|
||||||
@@ -274,13 +302,13 @@ const Page: React.FC = () => {
|
|||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<Text className="opacity-50 mb-1 text-xs">
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
Issue Type
|
{t("jellyseerr.issue_type")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{issueType
|
{issueType
|
||||||
? IssueTypeName[issueType]
|
? IssueTypeName[issueType]
|
||||||
: "Select an issue"}
|
: t("jellyseerr.select_an_issue")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -294,7 +322,9 @@ const Page: React.FC = () => {
|
|||||||
collisionPadding={0}
|
collisionPadding={0}
|
||||||
sideOffset={0}
|
sideOffset={0}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Types</DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
|
{t("jellyseerr.types")}
|
||||||
|
</DropdownMenu.Label>
|
||||||
{Object.entries(IssueTypeName)
|
{Object.entries(IssueTypeName)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(([key, value], idx) => (
|
.map(([key, value], idx) => (
|
||||||
@@ -319,7 +349,7 @@ const Page: React.FC = () => {
|
|||||||
maxLength={254}
|
maxLength={254}
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode="always"
|
clearButtonMode="always"
|
||||||
placeholder="(optional) Describe the issue..."
|
placeholder={t("jellyseerr.describe_the_issue")}
|
||||||
placeholderTextColor="#9CA3AF"
|
placeholderTextColor="#9CA3AF"
|
||||||
// Issue with multiline + Textinput inside a portal
|
// Issue with multiline + Textinput inside a portal
|
||||||
// https://github.com/callstack/react-native-paper/issues/1668
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
@@ -329,7 +359,7 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||||
Submit
|
{t("jellyseerr.submit_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
useLocalSearchParams,
|
||||||
|
useSegments,
|
||||||
|
} from "expo-router";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import {orderBy, uniqBy} from "lodash";
|
||||||
|
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||||
|
|
||||||
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "person", personId],
|
||||||
|
queryFn: async () => ({
|
||||||
|
details: await jellyseerrApi?.personDetails(personId),
|
||||||
|
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
|
||||||
|
}),
|
||||||
|
enabled: !!jellyseerrApi && !!personId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const castedRoles: PersonCreditCast[] = useMemo(
|
||||||
|
() =>
|
||||||
|
uniqBy(orderBy(
|
||||||
|
data?.combinedCredits?.cast,
|
||||||
|
["voteCount", "voteAverage"],
|
||||||
|
"desc"
|
||||||
|
), 'id'),
|
||||||
|
[data?.combinedCredits]
|
||||||
|
);
|
||||||
|
const backdrops = useMemo(
|
||||||
|
() => jellyseerrApi
|
||||||
|
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
|
||||||
|
: [],
|
||||||
|
[jellyseerrApi, data?.combinedCredits]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParallaxSlideShow
|
||||||
|
data={castedRoles}
|
||||||
|
images={backdrops}
|
||||||
|
listHeader={t("jellyseerr.appearances")}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
logo={
|
||||||
|
<Image
|
||||||
|
key={data?.details?.id}
|
||||||
|
id={data?.details?.id.toString()}
|
||||||
|
className="rounded-full bottom-1"
|
||||||
|
source={{
|
||||||
|
uri: jellyseerrApi?.imageProxy(
|
||||||
|
data?.details?.profilePath,
|
||||||
|
"w600_and_h600_bestv2"
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
style={{
|
||||||
|
width: 125,
|
||||||
|
height: 125,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
HeaderContent={() => (
|
||||||
|
<>
|
||||||
|
<Text className="font-bold text-2xl mb-1">
|
||||||
|
{data?.details?.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="opacity-50">
|
||||||
|
{t("jellyseerr.born")}{" "}
|
||||||
|
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
||||||
|
`${locale}-${region}`,
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}
|
||||||
|
)}{" "}
|
||||||
|
| {data?.details?.placeOfBirth}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
MainContent={() => (
|
||||||
|
<OverviewText text={data?.details?.biography} className="mt-4" />
|
||||||
|
)}
|
||||||
|
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const HOUR_HEIGHT = 30;
|
const HOUR_HEIGHT = 30;
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
@@ -177,6 +178,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
|||||||
onNextPage,
|
onNextPage,
|
||||||
isNextDisabled,
|
isNextDisabled,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -194,7 +196,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
|||||||
currentPage === 1 ? "text-gray-500" : "text-white"
|
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Previous
|
{t("live_tv.previous")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="text-white">Page {currentPage}</Text>
|
<Text className="text-white">Page {currentPage}</Text>
|
||||||
@@ -206,7 +208,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||||
>
|
>
|
||||||
Next
|
{t("live_tv.next")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-forward"
|
name="chevron-forward"
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import { useAtom } from "jotai";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -28,7 +31,7 @@ export default function page() {
|
|||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "recommended"]}
|
queryKey={["livetv", "recommended"]}
|
||||||
title={"On now"}
|
title={t("live_tv.on_now")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||||
@@ -46,7 +49,7 @@ export default function page() {
|
|||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "shows"]}
|
queryKey={["livetv", "shows"]}
|
||||||
title={"Shows"}
|
title={t("live_tv.shows")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
@@ -68,7 +71,7 @@ export default function page() {
|
|||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "movies"]}
|
queryKey={["livetv", "movies"]}
|
||||||
title={"Movies"}
|
title={t("live_tv.movies")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
@@ -86,7 +89,7 @@ export default function page() {
|
|||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "sports"]}
|
queryKey={["livetv", "sports"]}
|
||||||
title={"Sports"}
|
title={t("live_tv.sports")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
@@ -104,7 +107,7 @@ export default function page() {
|
|||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "kids"]}
|
queryKey={["livetv", "kids"]}
|
||||||
title={"For Kids"}
|
title={t("live_tv.for_kids")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
@@ -122,7 +125,7 @@ export default function page() {
|
|||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "news"]}
|
queryKey={["livetv", "news"]}
|
||||||
title={"News"}
|
title={t("live_tv.news")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View className="flex items-center justify-center h-full -mt-12">
|
<View className="flex items-center justify-center h-full -mt-12">
|
||||||
<Text>Coming soon</Text>
|
<Text>{t("live_tv.coming_soon")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo } from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
const { id: seriesId, seasonIndex } = params as {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -82,22 +84,26 @@ const page: React.FC = () => {
|
|||||||
allEpisodes &&
|
allEpisodes &&
|
||||||
allEpisodes.length > 0 && (
|
allEpisodes.length > 0 && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<AddToFavorites item={item} type="series" />
|
<AddToFavorites item={item} />
|
||||||
<DownloadItems
|
{!Platform.isTV && (
|
||||||
size="large"
|
<>
|
||||||
title="Download Series"
|
<DownloadItems
|
||||||
items={allEpisodes || []}
|
size="large"
|
||||||
MissingDownloadIconComponent={() => (
|
title={t("item_card.download.download_series")}
|
||||||
<Ionicons name="download" size={22} color="white" />
|
items={allEpisodes || []}
|
||||||
)}
|
MissingDownloadIconComponent={() => (
|
||||||
DownloadedIconComponent={() => (
|
<Ionicons name="download" size={22} color="white" />
|
||||||
<Ionicons
|
)}
|
||||||
name="checkmark-done-outline"
|
DownloadedIconComponent={() => (
|
||||||
size={24}
|
<Ionicons
|
||||||
color="#9333ea"
|
name="checkmark-done-outline"
|
||||||
|
size={24}
|
||||||
|
color="#9333ea"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
/>
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
@@ -41,6 +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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -62,6 +63,8 @@ const Page = () => {
|
|||||||
|
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sop) {
|
if (sop) {
|
||||||
@@ -150,8 +153,6 @@ const Page = () => {
|
|||||||
itemType = "Series";
|
itemType = "Series";
|
||||||
} else if (library.CollectionType === "boxsets") {
|
} else if (library.CollectionType === "boxsets") {
|
||||||
itemType = "BoxSet";
|
itemType = "BoxSet";
|
||||||
} else if (library.CollectionType === "music") {
|
|
||||||
itemType = "MusicAlbum";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
@@ -300,7 +301,7 @@ const Page = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedGenres}
|
set={setSelectedGenres}
|
||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title="Genres"
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -327,7 +328,7 @@ const Page = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedYears}
|
set={setSelectedYears}
|
||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title="Years"
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
/>
|
/>
|
||||||
@@ -352,7 +353,7 @@ const Page = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedTags}
|
set={setSelectedTags}
|
||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title="Tags"
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -370,7 +371,7 @@ const Page = () => {
|
|||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
title="Sort By"
|
title={t("library.filters.sort_by")}
|
||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
@@ -390,7 +391,7 @@ const Page = () => {
|
|||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
title="Sort Order"
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
@@ -436,7 +437,7 @@ const Page = () => {
|
|||||||
if (flatData.length === 0)
|
if (flatData.length === 0)
|
||||||
return (
|
return (
|
||||||
<View className="h-full w-full flex justify-center items-center">
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
<Text className="text-lg text-neutral-500">No items found</Text>
|
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -445,7 +446,7 @@ const Page = () => {
|
|||||||
key={orientation}
|
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">{t("library.no_results")}</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!settings?.libraryOptions) return null;
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
@@ -17,172 +20,178 @@ export default function IndexLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Library",
|
headerTitle: t("tabs.library"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () =>
|
||||||
<DropdownMenu.Root>
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
<DropdownMenu.Trigger>
|
!Platform.isTV && (
|
||||||
<Ionicons
|
<DropdownMenu.Root>
|
||||||
name="ellipsis-horizontal-outline"
|
<DropdownMenu.Trigger>
|
||||||
size={24}
|
<Ionicons
|
||||||
color="white"
|
name="ellipsis-horizontal-outline"
|
||||||
/>
|
size={24}
|
||||||
</DropdownMenu.Trigger>
|
color="white"
|
||||||
<DropdownMenu.Content
|
/>
|
||||||
align={"end"}
|
</DropdownMenu.Trigger>
|
||||||
alignOffset={-10}
|
<DropdownMenu.Content
|
||||||
avoidCollisions={false}
|
align={"end"}
|
||||||
collisionPadding={0}
|
alignOffset={-10}
|
||||||
loop={false}
|
avoidCollisions={false}
|
||||||
side={"bottom"}
|
collisionPadding={0}
|
||||||
sideOffset={10}
|
loop={false}
|
||||||
>
|
side={"bottom"}
|
||||||
<DropdownMenu.Label>Display</DropdownMenu.Label>
|
sideOffset={10}
|
||||||
<DropdownMenu.Group key="display-group">
|
>
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Label>
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
{t("library.options.display")}
|
||||||
Display
|
</DropdownMenu.Label>
|
||||||
</DropdownMenu.SubTrigger>
|
<DropdownMenu.Group key="display-group">
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.Sub>
|
||||||
alignOffset={-10}
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
avoidCollisions={true}
|
{t("library.options.display")}
|
||||||
collisionPadding={0}
|
</DropdownMenu.SubTrigger>
|
||||||
loop={true}
|
<DropdownMenu.SubContent
|
||||||
sideOffset={10}
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="display-option-1"
|
||||||
|
value={settings.libraryOptions.display === "row"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "row",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="display-title-1">
|
||||||
|
{t("library.options.row")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="display-option-2"
|
||||||
|
value={settings.libraryOptions.display === "list"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "list",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="display-title-2">
|
||||||
|
{t("library.options.list")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
|
{t("library.options.image_style")}
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="poster-option"
|
||||||
|
value={
|
||||||
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
|
}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "poster",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="poster-title">
|
||||||
|
{t("library.options.poster")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="cover-option"
|
||||||
|
value={settings.libraryOptions.imageStyle === "cover"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="cover-title">
|
||||||
|
{t("library.options.cover")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Group key="show-titles-group">
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||||
|
key="show-titles-option"
|
||||||
|
value={settings.libraryOptions.showTitles}
|
||||||
|
onValueChange={(newValue: string) => {
|
||||||
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
|
return;
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: newValue === "on" ? true : false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.ItemIndicator />
|
||||||
key="display-option-1"
|
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||||
value={settings.libraryOptions.display === "row"}
|
{t("library.options.show_titles")}
|
||||||
onValueChange={() =>
|
</DropdownMenu.ItemTitle>
|
||||||
updateSettings({
|
</DropdownMenu.CheckboxItem>
|
||||||
libraryOptions: {
|
<DropdownMenu.CheckboxItem
|
||||||
...settings.libraryOptions,
|
key="show-stats-option"
|
||||||
display: "row",
|
value={settings.libraryOptions.showStats}
|
||||||
},
|
onValueChange={(newValue: string) => {
|
||||||
})
|
updateSettings({
|
||||||
}
|
libraryOptions: {
|
||||||
>
|
...settings.libraryOptions,
|
||||||
<DropdownMenu.ItemIndicator />
|
showStats: newValue === "on" ? true : false,
|
||||||
<DropdownMenu.ItemTitle key="display-title-1">
|
},
|
||||||
Row
|
});
|
||||||
</DropdownMenu.ItemTitle>
|
}}
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="display-option-2"
|
|
||||||
value={settings.libraryOptions.display === "list"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
display: "list",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="display-title-2">
|
|
||||||
List
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
|
||||||
Image style
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.ItemIndicator />
|
||||||
key="poster-option"
|
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||||
value={settings.libraryOptions.imageStyle === "poster"}
|
{t("library.options.show_stats")}
|
||||||
onValueChange={() =>
|
</DropdownMenu.ItemTitle>
|
||||||
updateSettings({
|
</DropdownMenu.CheckboxItem>
|
||||||
libraryOptions: {
|
</DropdownMenu.Group>
|
||||||
...settings.libraryOptions,
|
|
||||||
imageStyle: "poster",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="poster-title">
|
|
||||||
Poster
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="cover-option"
|
|
||||||
value={settings.libraryOptions.imageStyle === "cover"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
imageStyle: "cover",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="cover-title">
|
|
||||||
Cover
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
<DropdownMenu.Group key="show-titles-group">
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
|
||||||
key="show-titles-option"
|
|
||||||
value={settings.libraryOptions.showTitles}
|
|
||||||
onValueChange={(newValue) => {
|
|
||||||
if (settings.libraryOptions.imageStyle === "poster")
|
|
||||||
return;
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showTitles: newValue === "on" ? true : false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
|
||||||
Show titles
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="show-stats-option"
|
|
||||||
value={settings.libraryOptions.showStats}
|
|
||||||
onValueChange={(newValue) => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showStats: newValue === "on" ? true : false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
|
||||||
Show stats
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
|
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useAtom } from "jotai";
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -20,6 +21,8 @@ export default function index() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, isLoading: isLoading } = useQuery({
|
const { data, isLoading: isLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -33,7 +36,11 @@ export default function index() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const libraries = useMemo(
|
const libraries = useMemo(
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
() =>
|
||||||
|
data
|
||||||
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
|
.filter((l) => l.CollectionType !== "music")
|
||||||
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
[data, settings?.hiddenLibraries]
|
[data, settings?.hiddenLibraries]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,7 +73,7 @@ export default function index() {
|
|||||||
if (!libraries)
|
if (!libraries)
|
||||||
return (
|
return (
|
||||||
<View className="h-full w-full flex justify-center items-center">
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
} from "@/components/stacks/NestedTabPageStack";
|
} from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -13,7 +15,7 @@ export default function SearchLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Search",
|
headerTitle: t("tabs.search"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
@@ -36,7 +38,18 @@ export default function SearchLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||||
<Stack.Screen name="jellyseerr/[personId]" options={commonScreenOptions} />
|
<Stack.Screen
|
||||||
|
name="jellyseerr/person/[personId]"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="jellyseerr/company/[companyId]"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="jellyseerr/genre/[genreId]"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ 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";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
|
import { Tag } from "@/components/GenreTags";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
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 { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
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 {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
@@ -20,29 +22,18 @@ import axios from "axios";
|
|||||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
PropsWithChildren,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { eventBus } from "@/utils/eventBus";
|
||||||
MovieResult,
|
|
||||||
PersonResult,
|
|
||||||
TvResult,
|
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
|
||||||
import { Tag } from "@/components/GenreTags";
|
|
||||||
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
|
||||||
import { sortBy } from "lodash";
|
|
||||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
|
||||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -59,7 +50,9 @@ export default function search() {
|
|||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { q } = params as { q: string };
|
||||||
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
@@ -129,22 +122,44 @@ export default function search() {
|
|||||||
[api, searchEngine, settings]
|
[api, searchEngine, settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type HeaderSearchBarRef = {
|
||||||
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
|
setText: (text: string) => void;
|
||||||
|
clearText: () => void;
|
||||||
|
cancelSearch: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchBarRef = useRef<HeaderSearchBarRef>(null);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (Platform.OS === "ios")
|
navigation.setOptions({
|
||||||
navigation.setOptions({
|
headerSearchBarOptions: {
|
||||||
headerSearchBarOptions: {
|
ref: searchBarRef,
|
||||||
placeholder: "Search...",
|
placeholder: t("search.search"),
|
||||||
onChangeText: (e: any) => {
|
onChangeText: (e: any) => {
|
||||||
router.setParams({ q: "" });
|
router.setParams({ q: "" });
|
||||||
setSearch(e.nativeEvent.text);
|
setSearch(e.nativeEvent.text);
|
||||||
},
|
|
||||||
hideWhenScrolling: false,
|
|
||||||
autoFocus: true,
|
|
||||||
},
|
},
|
||||||
});
|
hideWhenScrolling: false,
|
||||||
|
autoFocus: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = eventBus.on("searchTabPressed", () => {
|
||||||
|
// Screen not actuve
|
||||||
|
if (!searchBarRef.current) return;
|
||||||
|
// Screen is active, focus search bar
|
||||||
|
searchBarRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: movies, isFetching: l1 } = useQuery({
|
const { data: movies, isFetching: l1 } = useQuery({
|
||||||
queryKey: ["search", "movies", debouncedSearch],
|
queryKey: ["search", "movies", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -155,57 +170,6 @@ export default function search() {
|
|||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: jellyseerrResults, isFetching: j1 } = useReactNavigationQuery({
|
|
||||||
queryKey: ["search", "jellyseerr", "results", debouncedSearch],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await jellyseerrApi?.search({
|
|
||||||
query: new URLSearchParams(debouncedSearch).toString(),
|
|
||||||
page: 1, // todo: maybe rework page & page-size if first results are not enough...
|
|
||||||
language: "en",
|
|
||||||
});
|
|
||||||
|
|
||||||
return response?.results;
|
|
||||||
},
|
|
||||||
enabled:
|
|
||||||
!!jellyseerrApi &&
|
|
||||||
searchType === "Discover" &&
|
|
||||||
debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: jellyseerrDiscoverSettings, isFetching: j2 } =
|
|
||||||
useReactNavigationQuery({
|
|
||||||
queryKey: ["search", "jellyseerr", "discoverSettings", debouncedSearch],
|
|
||||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
|
||||||
enabled:
|
|
||||||
!!jellyseerrApi &&
|
|
||||||
searchType === "Discover" &&
|
|
||||||
debouncedSearch.length == 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
|
||||||
() =>
|
|
||||||
jellyseerrResults?.filter(
|
|
||||||
(r) => r.mediaType === MediaType.MOVIE
|
|
||||||
) as MovieResult[],
|
|
||||||
[jellyseerrResults]
|
|
||||||
);
|
|
||||||
|
|
||||||
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
|
|
||||||
() =>
|
|
||||||
jellyseerrResults?.filter(
|
|
||||||
(r) => r.mediaType === MediaType.TV
|
|
||||||
) as TvResult[],
|
|
||||||
[jellyseerrResults]
|
|
||||||
);
|
|
||||||
|
|
||||||
const jellyseerrPersonResults: PersonResult[] | undefined = useMemo(
|
|
||||||
() =>
|
|
||||||
jellyseerrResults?.filter(
|
|
||||||
(r) => r.mediaType === "person"
|
|
||||||
) as PersonResult[],
|
|
||||||
[jellyseerrResults]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: series, isFetching: l2 } = useQuery({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search", "series", debouncedSearch],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -246,64 +210,19 @@ export default function search() {
|
|||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: artists, isFetching: l4 } = useQuery({
|
|
||||||
queryKey: ["search", "artists", debouncedSearch],
|
|
||||||
queryFn: () =>
|
|
||||||
searchFn({
|
|
||||||
query: debouncedSearch,
|
|
||||||
types: ["MusicArtist"],
|
|
||||||
}),
|
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: albums, isFetching: l5 } = useQuery({
|
|
||||||
queryKey: ["search", "albums", debouncedSearch],
|
|
||||||
queryFn: () =>
|
|
||||||
searchFn({
|
|
||||||
query: debouncedSearch,
|
|
||||||
types: ["MusicAlbum"],
|
|
||||||
}),
|
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: songs, isFetching: l6 } = useQuery({
|
|
||||||
queryKey: ["search", "songs", debouncedSearch],
|
|
||||||
queryFn: () =>
|
|
||||||
searchFn({
|
|
||||||
query: debouncedSearch,
|
|
||||||
types: ["Audio"],
|
|
||||||
}),
|
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const noResults = useMemo(() => {
|
const noResults = useMemo(() => {
|
||||||
return !(
|
return !(
|
||||||
artists?.length ||
|
|
||||||
albums?.length ||
|
|
||||||
songs?.length ||
|
|
||||||
movies?.length ||
|
movies?.length ||
|
||||||
episodes?.length ||
|
episodes?.length ||
|
||||||
series?.length ||
|
series?.length ||
|
||||||
collections?.length ||
|
collections?.length ||
|
||||||
actors?.length ||
|
actors?.length
|
||||||
jellyseerrMovieResults?.length ||
|
|
||||||
jellyseerrTvResults?.length
|
|
||||||
);
|
);
|
||||||
}, [
|
}, [episodes, movies, series, collections, actors]);
|
||||||
artists,
|
|
||||||
episodes,
|
|
||||||
albums,
|
|
||||||
songs,
|
|
||||||
movies,
|
|
||||||
series,
|
|
||||||
collections,
|
|
||||||
actors,
|
|
||||||
jellyseerrResults,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
|
return l1 || l2 || l3 || l7 || l8;
|
||||||
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
|
}, [l1, l2, l3, l7, l8]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -315,24 +234,17 @@ export default function search() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col">
|
<View
|
||||||
{Platform.OS === "android" && (
|
className="flex flex-col"
|
||||||
<View className="mb-4 px-4">
|
style={{
|
||||||
<Input
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
autoCorrect={false}
|
}}
|
||||||
returnKeyType="done"
|
>
|
||||||
keyboardType="web-search"
|
|
||||||
placeholder="Search here..."
|
|
||||||
value={search}
|
|
||||||
onChangeText={(text) => setSearch(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
<Tag
|
<Tag
|
||||||
text="Library"
|
text={t("search.library")}
|
||||||
textClass="p-1"
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
@@ -341,7 +253,7 @@ export default function search() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
<Tag
|
<Tag
|
||||||
text="Discover"
|
text={t("search.discover")}
|
||||||
textClass="p-1"
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
@@ -350,17 +262,15 @@ export default function search() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{!!q && (
|
|
||||||
<View className="px-4 flex flex-col space-y-2">
|
<View className="mt-2">
|
||||||
<Text className="text-neutral-500 ">
|
<LoadingSkeleton isLoading={loading} />
|
||||||
Results for <Text className="text-purple-600">{q}</Text>
|
</View>
|
||||||
</Text>
|
|
||||||
</View>
|
{searchType === "Library" ? (
|
||||||
)}
|
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
{searchType === "Library" && (
|
|
||||||
<>
|
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header="Movies"
|
header={t("search.movies")}
|
||||||
ids={movies?.map((m) => m.Id!)}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -380,7 +290,7 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={series?.map((m) => m.Id!)}
|
ids={series?.map((m) => m.Id!)}
|
||||||
header="Series"
|
header={t("search.series")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
@@ -399,7 +309,7 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
header="Episodes"
|
header={t("search.episodes")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
@@ -413,7 +323,7 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={collections?.map((m) => m.Id!)}
|
ids={collections?.map((m) => m.Id!)}
|
||||||
header="Collections"
|
header={t("search.collections")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
@@ -429,7 +339,7 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={actors?.map((m) => m.Id!)}
|
ids={actors?.map((m) => m.Id!)}
|
||||||
header="Actors"
|
header={t("search.actors")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
@@ -441,181 +351,39 @@ export default function search() {
|
|||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
</View>
|
||||||
ids={artists?.map((m) => m.Id!)}
|
) : (
|
||||||
header="Artists"
|
<JellyserrIndexPage searchQuery={debouncedSearch} />
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
>
|
|
||||||
<AlbumCover id={item.Id} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={albums?.map((m) => m.Id!)}
|
|
||||||
header="Albums"
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
>
|
|
||||||
<AlbumCover id={item.Id} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={songs?.map((m) => m.Id!)}
|
|
||||||
header="Songs"
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
>
|
|
||||||
<AlbumCover id={item.AlbumId} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{searchType === "Discover" && (
|
|
||||||
<>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header="Request Movies"
|
|
||||||
items={jellyseerrMovieResults}
|
|
||||||
renderItem={(item: MovieResult) => (
|
|
||||||
<JellyseerrPoster item={item} key={item.id} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header="Request Series"
|
|
||||||
items={jellyseerrTvResults}
|
|
||||||
renderItem={(item: TvResult) => (
|
|
||||||
<JellyseerrPoster item={item} key={item.id} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header="Actors"
|
|
||||||
items={jellyseerrPersonResults}
|
|
||||||
renderItem={(item: PersonResult) => (
|
|
||||||
<PersonPoster
|
|
||||||
className="mr-2"
|
|
||||||
key={item.id}
|
|
||||||
id={item.id.toString()}
|
|
||||||
name={item.name}
|
|
||||||
posterPath={item.profilePath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{searchType === "Library" && (
|
||||||
<View className="mt-4 flex justify-center items-center">
|
<>
|
||||||
<Loader />
|
{!loading && noResults && debouncedSearch.length > 0 ? (
|
||||||
</View>
|
<View>
|
||||||
) : noResults && debouncedSearch.length > 0 ? (
|
<Text className="text-center text-lg font-bold mt-4">
|
||||||
<View>
|
{t("search.no_results_found_for")}
|
||||||
<Text className="text-center text-lg font-bold mt-4">
|
</Text>
|
||||||
No results found for
|
<Text className="text-xs text-purple-600 text-center">
|
||||||
</Text>
|
"{debouncedSearch}"
|
||||||
<Text className="text-xs text-purple-600 text-center">
|
</Text>
|
||||||
"{debouncedSearch}"
|
</View>
|
||||||
</Text>
|
) : debouncedSearch.length === 0 ? (
|
||||||
</View>
|
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||||
) : debouncedSearch.length === 0 && searchType === "Library" ? (
|
{exampleSearches.map((e) => (
|
||||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
<TouchableOpacity
|
||||||
{exampleSearches.map((e) => (
|
onPress={() => setSearch(e)}
|
||||||
<TouchableOpacity
|
key={e}
|
||||||
onPress={() => setSearch(e)}
|
className="mb-2"
|
||||||
key={e}
|
>
|
||||||
className="mb-2"
|
<Text className="text-purple-600">{e}</Text>
|
||||||
>
|
</TouchableOpacity>
|
||||||
<Text className="text-purple-600">{e}</Text>
|
))}
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
))}
|
) : null}
|
||||||
</View>
|
</>
|
||||||
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
)}
|
||||||
<View className="flex flex-col">
|
|
||||||
{sortBy?.(
|
|
||||||
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
|
||||||
"order"
|
|
||||||
).map((slide) => (
|
|
||||||
<DiscoverSlide key={slide.id} slide={slide} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props<T> = {
|
|
||||||
ids?: string[] | null;
|
|
||||||
items?: T[];
|
|
||||||
renderItem: (item: any) => React.ReactNode;
|
|
||||||
header?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SearchItemWrapper = <T extends unknown>({
|
|
||||||
ids,
|
|
||||||
items,
|
|
||||||
renderItem,
|
|
||||||
header,
|
|
||||||
}: PropsWithChildren<Props<T>>) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { data, isLoading: l1 } = useQuery({
|
|
||||||
queryKey: ["items", ids],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!user?.Id || !api || !ids || ids.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemPromises = ids.map((id) =>
|
|
||||||
getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await Promise.all(itemPromises);
|
|
||||||
|
|
||||||
// Filter out null items
|
|
||||||
return results.filter(
|
|
||||||
(item) => item !== null
|
|
||||||
) as unknown as BaseItemDto[];
|
|
||||||
},
|
|
||||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data && (!items || items.length === 0)) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
className="px-4 mb-2"
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{data && data?.length > 0
|
|
||||||
? data.map((item) => renderItem(item))
|
|
||||||
: items && items?.length > 0
|
|
||||||
? items.map((i) => renderItem(i))
|
|
||||||
: undefined}
|
|
||||||
</ScrollView>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useRef } from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
|
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
BottomTabNavigationOptions,
|
||||||
@@ -30,6 +31,7 @@ export const NativeTabs = withLayoutContext<
|
|||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -51,17 +53,24 @@ export default function TabLayout() {
|
|||||||
<>
|
<>
|
||||||
<SystemBars hidden={false} style="light" />
|
<SystemBars hidden={false} style="light" />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable
|
sidebarAdaptable={false}
|
||||||
ignoresTopSafeArea
|
ignoresTopSafeArea
|
||||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
tabBarStyle={{
|
||||||
|
backgroundColor: "#121212",
|
||||||
|
}}
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
scrollEdgeAppearance="default"
|
scrollEdgeAppearance="default"
|
||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name="index" />
|
<NativeTabs.Screen redirect name="index" />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
|
listeners={({ navigation }) => ({
|
||||||
|
tabPress: (e) => {
|
||||||
|
eventBus.emit("scrollToTop");
|
||||||
|
},
|
||||||
|
})}
|
||||||
name="(home)"
|
name="(home)"
|
||||||
options={{
|
options={{
|
||||||
title: "Home",
|
title: t("tabs.home"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
@@ -73,9 +82,14 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
|
listeners={({ navigation }) => ({
|
||||||
|
tabPress: (e) => {
|
||||||
|
eventBus.emit("searchTabPressed");
|
||||||
|
},
|
||||||
|
})}
|
||||||
name="(search)"
|
name="(search)"
|
||||||
options={{
|
options={{
|
||||||
title: "Search",
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
@@ -89,7 +103,7 @@ export default function TabLayout() {
|
|||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(favorites)"
|
name="(favorites)"
|
||||||
options={{
|
options={{
|
||||||
title: "Favorites",
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
@@ -105,7 +119,7 @@ export default function TabLayout() {
|
|||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(libraries)"
|
name="(libraries)"
|
||||||
options={{
|
options={{
|
||||||
title: "Library",
|
title: t("tabs.library"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
@@ -119,7 +133,7 @@ export default function TabLayout() {
|
|||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(custom-links)"
|
name="(custom-links)"
|
||||||
options={{
|
options={{
|
||||||
title: "Custom Links",
|
title: t("tabs.custom_links"),
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
|
|||||||
@@ -1,8 +1,33 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (settings.defaultVideoOrientation) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (settings.autoRotate === true) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
@@ -16,24 +41,6 @@ export default function Layout() {
|
|||||||
animation: "fade",
|
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>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,56 +3,40 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||||
import {
|
import {
|
||||||
|
PipStartedPayload,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||||
useCallback,
|
import { Alert, View, Platform } from "react-native";
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
BackHandler,
|
|
||||||
View,
|
|
||||||
AppState,
|
|
||||||
AppStateStatus,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import settings from "../(tabs)/(home)/settings";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { BaseItemDto, MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
@@ -60,12 +44,16 @@ export default function page() {
|
|||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
let getDownloadedItem = null;
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
getDownloadedItem = downloadProvider.useDownload();
|
||||||
|
}
|
||||||
|
|
||||||
const { getDownloadedItem } = useDownload();
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -91,145 +79,116 @@ export default function page() {
|
|||||||
offline: string;
|
offline: string;
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||||
const bitrateValue = bitrateValueStr
|
const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: BITRATES[0].value;
|
|
||||||
|
|
||||||
const {
|
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||||
data: item,
|
const [itemStatus, setItemStatus] = useState({
|
||||||
isLoading: isLoadingItem,
|
isLoading: true,
|
||||||
isError: isErrorItem,
|
isError: false,
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
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 {
|
useEffect(() => {
|
||||||
data: stream,
|
const fetchItemData = async () => {
|
||||||
isLoading: isLoadingStreamUrl,
|
setItemStatus({ isLoading: true, isError: false });
|
||||||
isError: isErrorStreamUrl,
|
try {
|
||||||
} = useQuery({
|
let fetchedItem: BaseItemDto | null = null;
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
if (offline && !Platform.isTV) {
|
||||||
queryFn: async () => {
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (offline) {
|
if (data) fetchedItem = data.item as BaseItemDto;
|
||||||
const data = await getDownloadedItem(itemId);
|
} else {
|
||||||
if (!data?.mediaSource) return null;
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
|
itemId,
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
userId: user?.Id,
|
||||||
|
});
|
||||||
if (item)
|
fetchedItem = res.data;
|
||||||
return {
|
}
|
||||||
mediaSource: data.mediaSource,
|
setItem(fetchedItem);
|
||||||
url,
|
} catch (error) {
|
||||||
sessionId: undefined,
|
console.error("Failed to fetch item:", error);
|
||||||
};
|
setItemStatus({ isLoading: false, isError: true });
|
||||||
|
} finally {
|
||||||
|
setItemStatus({ isLoading: false, isError: false });
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
if (itemId) {
|
||||||
api,
|
fetchItemData();
|
||||||
item,
|
}
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
}, [itemId, offline, api, user?.Id]);
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: native,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
interface Stream {
|
||||||
|
mediaSource: MediaSourceInfo;
|
||||||
|
sessionId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
const [stream, setStream] = useState<Stream | null>(null);
|
||||||
|
const [streamStatus, setStreamStatus] = useState({
|
||||||
if (!sessionId || !mediaSource || !url) {
|
isLoading: true,
|
||||||
Alert.alert("Error", "Failed to get stream url");
|
isError: false,
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStreamData = async () => {
|
||||||
|
try {
|
||||||
|
let result: Stream | null = null;
|
||||||
|
if (offline && !Platform.isTV) {
|
||||||
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
|
if (!data?.mediaSource) return;
|
||||||
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
|
if (item) {
|
||||||
|
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
if (!sessionId || !mediaSource || !url) {
|
||||||
|
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = { mediaSource, sessionId, url };
|
||||||
|
}
|
||||||
|
setStream(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch stream:", error);
|
||||||
|
setStreamStatus({ isLoading: false, isError: true });
|
||||||
|
} finally {
|
||||||
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchStreamData();
|
||||||
|
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
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]);
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
stream,
|
|
||||||
videoRef,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
offline,
|
|
||||||
progress.value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
const currentTimeInTicks = msToTicks(progress.value);
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
@@ -246,33 +205,23 @@ export default function page() {
|
|||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
}, [videoRef, reportPlaybackStopped]);
|
||||||
|
|
||||||
// TODO: unused should remove.
|
useEffect(() => {
|
||||||
const reportPlaybackStart = useCallback(async () => {
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
if (offline) return;
|
return () => {
|
||||||
|
beforeRemoveListener();
|
||||||
if (!stream) return;
|
};
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
}, [navigation, stop]);
|
||||||
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(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: ProgressUpdatePayload) => {
|
||||||
if (isSeeking.value === true) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
const { currentTime } = data.nativeEvent;
|
||||||
|
|
||||||
if (isBuffering) {
|
if (isBuffering) {
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.value = currentTime;
|
progress.set(currentTime);
|
||||||
|
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
@@ -291,11 +240,56 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
[item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering]
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||||
useOrientationSettings();
|
const { pipStarted } = e.nativeEvent;
|
||||||
|
setIsPipStarted(pipStarted);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changePlaybackState = useCallback(
|
||||||
|
async (isPlaying: boolean) => {
|
||||||
|
if (!api || offline || !stream) return;
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.get()),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startPosition = useMemo(() => {
|
||||||
|
if (offline) return 0;
|
||||||
|
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
|
if (offline || !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,
|
||||||
|
});
|
||||||
|
hasReportedRef.current = true;
|
||||||
|
}, [api, item, stream]);
|
||||||
|
|
||||||
|
const hasReportedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (stream && !hasReportedRef.current) {
|
||||||
|
reportPlaybackStart();
|
||||||
|
hasReportedRef.current = true; // Mark as reported
|
||||||
|
}
|
||||||
|
}, [stream]);
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
@@ -304,126 +298,85 @@ export default function page() {
|
|||||||
offline,
|
offline,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
const onPlaybackStateChanged = useCallback(
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
async (e: PlaybackStatePayload) => {
|
||||||
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
|
|
||||||
if (state === "Playing") {
|
if (state === "Playing") {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
return;
|
await changePlaybackState(true);
|
||||||
}
|
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||||
|
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();
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const [appState, setAppState] = useState(AppState.currentState);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
|
||||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
|
||||||
// Handle app coming to the foreground
|
|
||||||
} else if (nextAppState.match(/inactive|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
|
if (state === "Paused") {
|
||||||
const subscription = AppState.addEventListener(
|
setIsPlaying(false);
|
||||||
"change",
|
await changePlaybackState(false);
|
||||||
handleAppStateChange
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
if (isPlaying) {
|
||||||
// Cleanup the event listener when the component is unmounted
|
setIsPlaying(true);
|
||||||
subscription.remove();
|
setIsBuffering(false);
|
||||||
};
|
} else if (isBuffering) {
|
||||||
}, [appState]);
|
setIsBuffering(true);
|
||||||
|
}
|
||||||
// Preselection of audio and subtitle tracks.
|
},
|
||||||
|
[changePlaybackState]
|
||||||
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(
|
const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
|
||||||
(audio) => audio.Type === "Audio"
|
|
||||||
|
// Move all the external subtitles last, because vlc places them last.
|
||||||
|
const allSubs =
|
||||||
|
stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
|
||||||
|
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
|
const externalSubtitles = allSubs
|
||||||
|
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||||
|
.map((sub: any) => ({
|
||||||
|
name: sub.DisplayTitle,
|
||||||
|
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||||
|
|
||||||
|
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
|
||||||
// Direct playback CASE
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
if (!bitrateValue) {
|
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
// If Subtitle is embedded we can use the position to select it straight away.
|
if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
|
||||||
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
|
||||||
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
initOptions.push(`--sub-track=${finalIndex}`);
|
||||||
} 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)
|
if (notTranscoding && chosenAudioTrack) {
|
||||||
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
// Add useEffect to handle mounting
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
return () => setIsMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (itemStatus.isLoading || streamStatus.isLoading) {
|
||||||
return (
|
return (
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
if (!item || !stream || itemStatus.isError || streamStatus.isError)
|
||||||
return (
|
return (
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
<Text className="text-white">Error</Text>
|
<Text className="text-white">{t("player.error")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -437,37 +390,36 @@ export default function page() {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
||||||
|
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={{
|
source={{
|
||||||
uri: stream.url,
|
uri: stream?.url || "",
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
startPosition,
|
startPosition,
|
||||||
externalTrack,
|
externalSubtitles,
|
||||||
initOptions,
|
initOptions,
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onVideoProgress={onProgress}
|
onVideoProgress={onProgress}
|
||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
onVideoLoadStart={() => {}}
|
onPipStarted={onPipStarted}
|
||||||
onVideoLoadEnd={() => {
|
onVideoLoadEnd={() => {
|
||||||
setIsVideoLoaded(true);
|
setIsVideoLoaded(true);
|
||||||
}}
|
}}
|
||||||
onVideoError={(e) => {
|
onVideoError={(e) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
|
||||||
"Error",
|
|
||||||
"An error occurred while playing the video. Check logs in settings."
|
|
||||||
);
|
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && (
|
{videoRef.current && !isPipStarted && isMounted === true ? (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -483,6 +435,7 @@ export default function page() {
|
|||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
|
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
||||||
play={videoRef.current?.play}
|
play={videoRef.current?.play}
|
||||||
pause={videoRef.current?.pause}
|
pause={videoRef.current?.pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={videoRef.current?.seekTo}
|
||||||
@@ -493,29 +446,9 @@ export default function page() {
|
|||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
setAudioTrack={videoRef.current.setAudioTrack}
|
||||||
stop={stop}
|
|
||||||
isVlc
|
isVlc
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</View>
|
</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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,419 +0,0 @@
|
|||||||
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 { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
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 lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
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(() => {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
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 { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
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 lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
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);
|
|
||||||
lightHapticFeedback();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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) => {
|
|
||||||
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) => {
|
|
||||||
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;
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Link, Stack, usePathname } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
|
|||||||
468
app/_layout.tsx
468
app/_layout.tsx
@@ -1,5 +1,6 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Platform } from "react-native";
|
||||||
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
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 { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
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 { LogProvider, writeToLog } from "@/utils/log";
|
import { LogProvider, writeToLog } from "@/utils/log";
|
||||||
@@ -18,61 +18,73 @@ 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";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
const BackGroundDownloader = !Platform.isTV
|
||||||
checkForExistingDownloads,
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
completeHandler,
|
: null;
|
||||||
download,
|
|
||||||
} from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
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";
|
const BackgroundFetch = !Platform.isTV
|
||||||
|
? require("expo-background-fetch")
|
||||||
|
: null;
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useFonts } from "expo-font";
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
|
||||||
import * as Linking from "expo-linking";
|
|
||||||
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 SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Appearance, AppState, TouchableOpacity } from "react-native";
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
import { Appearance, AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
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";
|
||||||
|
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the splash screen visible while we fetch resources
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
// Set the animation options. This is optional.
|
||||||
handleNotification: async () => ({
|
SplashScreen.setOptions({
|
||||||
shouldShowAlert: true,
|
duration: 500,
|
||||||
shouldPlaySound: true,
|
fade: true,
|
||||||
shouldSetBadge: false,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
function redirect(notification: Notifications.Notification) {
|
function redirect(notification: typeof Notifications.Notification) {
|
||||||
const url = notification.request.content.data?.url;
|
const url = notification.request.content.data?.url;
|
||||||
if (url) {
|
if (url) {
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
Notifications.getLastNotificationResponseAsync().then(
|
||||||
if (!isMounted || !response?.notification) {
|
(response: { notification: any }) => {
|
||||||
return;
|
if (!isMounted || !response?.notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
redirect(response?.notification);
|
||||||
}
|
}
|
||||||
redirect(response?.notification);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
(response) => {
|
(response: { notification: any }) => {
|
||||||
redirect(response.notification);
|
redirect(response.notification);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -84,99 +96,101 @@ function useNotificationObserver() {
|
|||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
if (!Platform.isTV) {
|
||||||
console.log("TaskManager ~ trigger");
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url)
|
if (!settings?.autoDownload || !url)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
const deviceId = 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;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const jobs = await getAllJobsByDeviceId({
|
||||||
deviceId,
|
deviceId,
|
||||||
authHeader: token,
|
authHeader: token,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||||
|
|
||||||
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;
|
||||||
const tasks = await checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
if (tasks.find((task) => task.id === job.id)) {
|
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BackGroundDownloader.download({
|
||||||
|
id: job.id,
|
||||||
|
url: downloadUrl,
|
||||||
|
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.begin(() => {
|
||||||
|
console.log("TaskManager ~ Download started: ", job.id);
|
||||||
|
})
|
||||||
|
.done(() => {
|
||||||
|
console.log("TaskManager ~ Download completed: ", job.id);
|
||||||
|
saveDownloadedItemInfo(job.item);
|
||||||
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
|
cancelJobById({
|
||||||
|
authHeader: token,
|
||||||
|
id: job.id,
|
||||||
|
url: url,
|
||||||
|
});
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download completed",
|
||||||
|
data: {
|
||||||
|
url: `/downloads`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.error((error: any) => {
|
||||||
|
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||||
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download failed",
|
||||||
|
data: {
|
||||||
|
url: `/downloads`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
download({
|
|
||||||
id: job.id,
|
|
||||||
url: downloadUrl,
|
|
||||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.begin(() => {
|
|
||||||
console.log("TaskManager ~ Download started: ", job.id);
|
|
||||||
})
|
|
||||||
.done(() => {
|
|
||||||
console.log("TaskManager ~ Download completed: ", job.id);
|
|
||||||
saveDownloadedItemInfo(job.item);
|
|
||||||
completeHandler(job.id);
|
|
||||||
cancelJobById({
|
|
||||||
authHeader: token,
|
|
||||||
id: job.id,
|
|
||||||
url: url,
|
|
||||||
});
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download completed",
|
|
||||||
data: {
|
|
||||||
url: `/downloads`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.error((error) => {
|
|
||||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
|
||||||
completeHandler(job.id);
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download failed",
|
|
||||||
data: {
|
|
||||||
url: `/downloads`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
// Be sure to return the successful result type!
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const checkAndRequestPermissions = async () => {
|
const checkAndRequestPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -210,26 +224,18 @@ const checkAndRequestPermissions = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loaded) {
|
|
||||||
SplashScreen.hideAsync();
|
|
||||||
}
|
|
||||||
}, [loaded]);
|
|
||||||
|
|
||||||
Appearance.setColorScheme("dark");
|
Appearance.setColorScheme("dark");
|
||||||
|
|
||||||
if (!loaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JotaiProvider>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<Layout />
|
<JotaiProvider>
|
||||||
</JotaiProvider>
|
<ActionSheetProvider>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<Layout />
|
||||||
|
</I18nextProvider>
|
||||||
|
</ActionSheetProvider>
|
||||||
|
</JotaiProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,131 +252,117 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
|
||||||
|
|
||||||
useKeepAwake();
|
|
||||||
useNotificationObserver();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkAndRequestPermissions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.autoRotate === true)
|
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
|
||||||
else
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
const appState = useRef(AppState.currentState);
|
const appState = useRef(AppState.currentState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
i18n.changeLanguage(
|
||||||
if (
|
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
|
||||||
appState.current.match(/inactive|background/) &&
|
|
||||||
nextAppState === "active"
|
|
||||||
) {
|
|
||||||
checkForExistingDownloads();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
checkForExistingDownloads();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
|
||||||
(event) => {
|
|
||||||
setOrientation(event.orientationInfo.orientation);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
if (!Platform.isTV) {
|
||||||
setOrientation(initialOrientation);
|
useNotificationObserver();
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
checkAndRequestPermissions();
|
||||||
};
|
}, []);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const url = Linking.useURL();
|
useEffect(() => {
|
||||||
|
// If the user has auto rotate enabled, unlock the orientation
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
if (settings.autoRotate === true) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
// If the user has auto rotate disabled, lock the orientation to portrait
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
if (url) {
|
useEffect(() => {
|
||||||
const { hostname, path, queryParams } = Linking.parse(url);
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
(nextAppState) => {
|
||||||
|
if (
|
||||||
|
appState.current.match(/inactive|background/) &&
|
||||||
|
nextAppState === "active"
|
||||||
|
) {
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<JobQueueProvider>
|
||||||
<ActionSheetProvider>
|
<JellyfinProvider>
|
||||||
<JobQueueProvider>
|
<PlaySettingsProvider>
|
||||||
<JellyfinProvider>
|
<LogProvider>
|
||||||
<PlaySettingsProvider>
|
<WebSocketProvider>
|
||||||
<LogProvider>
|
<DownloadProvider>
|
||||||
<WebSocketProvider>
|
<BottomSheetModalProvider>
|
||||||
<DownloadProvider>
|
<SystemBars style="light" hidden={false} />
|
||||||
<BottomSheetModalProvider>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<SystemBars style="light" hidden={false} />
|
<Stack initialRouteName="(auth)/(tabs)">
|
||||||
<ThemeProvider value={DarkTheme}>
|
<Stack.Screen
|
||||||
<Stack initialRouteName="/home">
|
name="(auth)/(tabs)"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="(auth)/(tabs)"
|
headerShown: false,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: false,
|
header: () => null,
|
||||||
title: "",
|
}}
|
||||||
header: () => null,
|
/>
|
||||||
}}
|
<Stack.Screen
|
||||||
/>
|
name="(auth)/player"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="(auth)/player"
|
headerShown: false,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: false,
|
header: () => null,
|
||||||
title: "",
|
}}
|
||||||
header: () => null,
|
/>
|
||||||
}}
|
<Stack.Screen
|
||||||
/>
|
name="login"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="login"
|
headerShown: true,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: true,
|
headerTransparent: true,
|
||||||
title: "",
|
}}
|
||||||
headerTransparent: true,
|
/>
|
||||||
}}
|
<Stack.Screen name="+not-found" />
|
||||||
/>
|
</Stack>
|
||||||
<Stack.Screen name="+not-found" />
|
<Toaster
|
||||||
</Stack>
|
duration={4000}
|
||||||
<Toaster
|
toastOptions={{
|
||||||
duration={4000}
|
style: {
|
||||||
toastOptions={{
|
backgroundColor: "#262626",
|
||||||
style: {
|
borderColor: "#363639",
|
||||||
backgroundColor: "#262626",
|
borderWidth: 1,
|
||||||
borderColor: "#363639",
|
},
|
||||||
borderWidth: 1,
|
titleStyle: {
|
||||||
},
|
color: "white",
|
||||||
titleStyle: {
|
},
|
||||||
color: "white",
|
}}
|
||||||
},
|
closeButton
|
||||||
}}
|
/>
|
||||||
closeButton
|
</ThemeProvider>
|
||||||
/>
|
</BottomSheetModalProvider>
|
||||||
</ThemeProvider>
|
</DownloadProvider>
|
||||||
</BottomSheetModalProvider>
|
</WebSocketProvider>
|
||||||
</DownloadProvider>
|
</LogProvider>
|
||||||
</WebSocketProvider>
|
</PlaySettingsProvider>
|
||||||
</LogProvider>
|
</JellyfinProvider>
|
||||||
</PlaySettingsProvider>
|
</JobQueueProvider>
|
||||||
</JellyfinProvider>
|
</QueryClientProvider>
|
||||||
</JobQueueProvider>
|
|
||||||
</ActionSheetProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
app/login.tsx
105
app/login.tsx
@@ -1,19 +1,15 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
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 JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
Ionicons,
|
|
||||||
MaterialCommunityIcons,
|
|
||||||
MaterialIcons,
|
|
||||||
} 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 { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -23,18 +19,20 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { Keyboard } from "react-native";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { t } from "i18next";
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, "Username is required"),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||||
useJellyfin();
|
useJellyfin();
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -42,6 +40,8 @@ const Login: React.FC = () => {
|
|||||||
password: _password,
|
password: _password,
|
||||||
} = params as { apiUrl: string; username: string; password: string };
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
@@ -52,10 +52,11 @@ const Login: React.FC = () => {
|
|||||||
password: _password,
|
password: _password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way to auto login based on a link
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
// we might re-use the checkUrl function here to check the url as well
|
|
||||||
// however, I don't think it should be necessary for now
|
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
setServer({
|
setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
@@ -71,7 +72,6 @@ const Login: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerTitle: serverName,
|
headerTitle: serverName,
|
||||||
@@ -84,15 +84,17 @@ const Login: React.FC = () => {
|
|||||||
className="flex flex-row items-center"
|
className="flex flex-row items-center"
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||||
<Text className="ml-2 text-purple-600">Change server</Text>
|
<Text className="ml-2 text-purple-600">
|
||||||
|
{t("login.change_server")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
}, [serverName, navigation, api?.basePath]);
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
@@ -101,17 +103,18 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert("Connection failed", error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert("Connection failed", "An unexpected error occurred");
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.an_unexpected_error_occured")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
* Checks the availability and validity of a Jellyfin server URL.
|
||||||
*
|
*
|
||||||
@@ -167,14 +170,13 @@ const Login: React.FC = () => {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const handleConnect = useCallback(async (url: string) => {
|
const handleConnect = useCallback(async (url: string) => {
|
||||||
url = url.trim();
|
url = url.trim().replace(/\/$/, "");
|
||||||
|
|
||||||
const result = await checkUrl(url);
|
const result = await checkUrl(url);
|
||||||
|
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Connection failed",
|
t("login.connection_failed"),
|
||||||
"Could not connect to the server. Please check the URL and your network connection."
|
t("login.could_not_connect_to_server")
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,14 +188,21 @@ const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert("Quick Connect", `Enter code ${code} to login`, [
|
Alert.alert(
|
||||||
{
|
t("login.quick_connect"),
|
||||||
text: "Got It",
|
t("login.enter_code_to_login", { code: code }),
|
||||||
},
|
[
|
||||||
]);
|
{
|
||||||
|
text: t("login.got_it"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert("Error", "Failed to initiate Quick Connect");
|
Alert.alert(
|
||||||
|
t("login.error_title"),
|
||||||
|
t("login.failed_to_initiate_quick_connect")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -208,26 +217,26 @@ const Login: React.FC = () => {
|
|||||||
<View className="px-4 -mt-20 w-full">
|
<View className="px-4 -mt-20 w-full">
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="text-2xl font-bold -mb-2">
|
<Text className="text-2xl font-bold -mb-2">
|
||||||
Log in
|
|
||||||
<>
|
<>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
<>
|
<>
|
||||||
{" to "}
|
{t("login.login_to_title") + " "}
|
||||||
<Text className="text-purple-600">{serverName}</Text>
|
<Text className="text-purple-600">{serverName}</Text>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
t("login.login_title")
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400">
|
<Text className="text-xs text-neutral-400">
|
||||||
{api.basePath}
|
{api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
autoFocus
|
|
||||||
secureTextEntry={false}
|
secureTextEntry={false}
|
||||||
keyboardType="default"
|
keyboardType="default"
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
@@ -238,7 +247,7 @@ const Login: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Password"
|
placeholder={t("login.password_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
@@ -257,7 +266,7 @@ const Login: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
className="flex-1 mr-2"
|
className="flex-1 mr-2"
|
||||||
>
|
>
|
||||||
Log in
|
{t("login.login_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleQuickConnect}
|
onPress={handleQuickConnect}
|
||||||
@@ -291,11 +300,11 @@ 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">
|
||||||
Enter the URL to your Jellyfin server
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label="Server URL"
|
aria-label="Server URL"
|
||||||
placeholder="http(s)://your-server.com"
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
@@ -304,15 +313,25 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
loading={loadingServerCheck}
|
loading={loadingServerCheck}
|
||||||
disabled={loadingServerCheck}
|
disabled={loadingServerCheck}
|
||||||
onPress={async () => await handleConnect(serverURL)}
|
onPress={async () => {
|
||||||
|
await handleConnect(serverURL);
|
||||||
|
}}
|
||||||
className="w-full grow"
|
className="w-full grow"
|
||||||
>
|
>
|
||||||
Connect
|
{t("server.connect_button")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<JellyfinServerDiscovery
|
||||||
|
onServerSelect={(server) => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<PreviousServersList
|
<PreviousServersList
|
||||||
onServerSelect={(s) => {
|
onServerSelect={(s) => {
|
||||||
handleConnect(s.address);
|
handleConnect(s.address);
|
||||||
|
|||||||
46
augmentations/api.ts
Normal file
46
augmentations/api.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
||||||
|
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
|
import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
declare module "@jellyfin/sdk" {
|
||||||
|
interface Api {
|
||||||
|
get<T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config?: AxiosRequestConfig<D>
|
||||||
|
): Promise<AxiosResponse<T>>;
|
||||||
|
post<T, D = any>(
|
||||||
|
url: string,
|
||||||
|
data: D,
|
||||||
|
config?: AxiosRequestConfig<D>
|
||||||
|
): Promise<AxiosResponse<T>>;
|
||||||
|
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Api.prototype.get = function <T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config: AxiosRequestConfig<D> = {}
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
|
||||||
|
...(config ?? {}),
|
||||||
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Api.prototype.post = function <T, D = any>(
|
||||||
|
url: string,
|
||||||
|
data: D,
|
||||||
|
config: AxiosRequestConfig<D>
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
|
||||||
|
...(config || {}),
|
||||||
|
data,
|
||||||
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Api.prototype.getStreamyfinPluginConfig = function (): Promise<
|
||||||
|
AxiosResponse<StreamyfinPluginConfig>
|
||||||
|
> {
|
||||||
|
return this.get<StreamyfinPluginConfig>("/Streamyfin/config");
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
export * from "./string";
|
export * from "./string";
|
||||||
|
|||||||
@@ -13,5 +13,10 @@ MMKV.prototype.get = function <T> (key: string): T | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
this.set(key, JSON.stringify(value));
|
if (value === undefined) {
|
||||||
|
this.delete(key)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface Number {
|
interface Number {
|
||||||
bytesToReadable(): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
hoursToMilliseconds(): number;
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Number.prototype.bytesToReadable = function () {
|
Number.prototype.bytesToReadable = function (decimals: number = 2) {
|
||||||
const bytes = this.valueOf();
|
const bytes = this.valueOf();
|
||||||
const gb = bytes / 1e9;
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
if (gb >= 1) return `${gb.toFixed(0)} GB`;
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
|
||||||
const mb = bytes / 1024.0 / 1024.0;
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
if (mb >= 1) return `${mb.toFixed(0)} MB`;
|
|
||||||
|
|
||||||
const kb = bytes / 1024.0;
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
if (kb >= 1) return `${kb.toFixed(0)} KB`;
|
|
||||||
|
|
||||||
return `${bytes.toFixed(2)} B`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Number.prototype.secondsToMilliseconds = function () {
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
|
|||||||
@@ -1,113 +1,23 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { View } from "react-native";
|
||||||
import { useAtom } from "jotai";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
type: "item" | "series";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
|
export const AddToFavorites = ({ item, ...props }) => {
|
||||||
const queryClient = useQueryClient();
|
const { isFavorite, toggleFavorite, _} = useFavorite(item);
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const isFavorite = useMemo(() => {
|
|
||||||
return item.UserData?.IsFavorite;
|
|
||||||
}, [item.UserData?.IsFavorite]);
|
|
||||||
|
|
||||||
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
|
||||||
queryClient.setQueryData<BaseItemDto | undefined>(
|
|
||||||
[type, item.Id],
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
...newData,
|
|
||||||
UserData: { ...old.UserData, ...newData.UserData },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const markFavoriteMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (api && user) {
|
|
||||||
await getUserLibraryApi(api).markFavoriteItem({
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: item.Id!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
|
||||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
|
||||||
type,
|
|
||||||
item.Id,
|
|
||||||
]);
|
|
||||||
updateItemInQueries({ UserData: { IsFavorite: true } });
|
|
||||||
|
|
||||||
return { previousItem };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousItem) {
|
|
||||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const unmarkFavoriteMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (api && user) {
|
|
||||||
await getUserLibraryApi(api).unmarkFavoriteItem({
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: item.Id!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
|
||||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
|
||||||
type,
|
|
||||||
item.Id,
|
|
||||||
]);
|
|
||||||
updateItemInQueries({ UserData: { IsFavorite: false } });
|
|
||||||
|
|
||||||
return { previousItem };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousItem) {
|
|
||||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size="large"
|
size="large"
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
fillColor={isFavorite ? "primary" : undefined}
|
fillColor={isFavorite ? "primary" : undefined}
|
||||||
onPress={() => {
|
onPress={toggleFavorite}
|
||||||
if (isFavorite) {
|
|
||||||
unmarkFavoriteMutation.mutate();
|
|
||||||
} else {
|
|
||||||
markFavoriteMutation.mutate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -16,6 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
@@ -26,6 +28,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
[audioStreams, selected]
|
[audioStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
@@ -36,7 +40,9 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.audio")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text className="" numberOfLines={1}>
|
<Text className="" numberOfLines={1}>
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
{selectedAudioSteam?.DisplayTitle}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -27,6 +28,10 @@ export const BITRATES: Bitrate[] = [
|
|||||||
key: "2 Mb/s",
|
key: "2 Mb/s",
|
||||||
value: 2000000,
|
value: 2000000,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "1 Mb/s",
|
||||||
|
value: 1000000,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "500 Kb/s",
|
key: "500 Kb/s",
|
||||||
value: 500000,
|
value: 500000,
|
||||||
@@ -49,6 +54,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
inverted,
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
@@ -59,6 +65,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
@@ -70,7 +78,9 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.quality")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
|
||||||
import React, { useCallback, 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, {
|
||||||
@@ -18,12 +17,12 @@ interface Props extends ViewProps {
|
|||||||
background?: "blur" | "transparent";
|
background?: "blur" | "transparent";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Chromecast: React.FC<Props> = ({
|
export function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
height = 48,
|
height = 48,
|
||||||
background = "transparent",
|
background = "transparent",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) {
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const devices = useDevices();
|
const devices = useDevices();
|
||||||
@@ -83,4 +82,4 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
0
components/Chromecast.tv.tsx
Normal file
0
components/Chromecast.tv.tsx
Normal file
1
components/ContextMenu.ts
Normal file
1
components/ContextMenu.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "zeego/context-menu";
|
||||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
@@ -49,6 +49,11 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
else
|
else
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.ImageTags?.["Thumb"])
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||||
|
else
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
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 { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { Href, router, useFocusEffect } from "expo-router";
|
import { Href, router, useFocusEffect } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, View, ViewProps } from "react-native";
|
import { Alert, Platform, View, ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
@@ -32,6 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
|
|||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
import { t } from "i18next";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
@@ -55,6 +56,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
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, downloadedFiles } = useDownload();
|
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||||
const { startRemuxing } = useRemuxHlsToMp4();
|
const { startRemuxing } = useRemuxHlsToMp4();
|
||||||
|
|
||||||
@@ -64,17 +66,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
||||||
key: "Max",
|
settings?.defaultBitrate ?? {
|
||||||
value: undefined,
|
key: "Max",
|
||||||
});
|
value: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const userCanDownload = useMemo(
|
const userCanDownload = useMemo(
|
||||||
() => user?.Policy?.EnableContentDownloading,
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
[user]
|
[user]
|
||||||
);
|
);
|
||||||
const usingOptimizedServer = useMemo(
|
const usingOptimizedServer = useMemo(
|
||||||
() => settings?.downloadMethod === "optimized",
|
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||||
[settings]
|
[settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,7 +164,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error("You are not allowed to download files.");
|
toast.error(
|
||||||
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
queue,
|
queue,
|
||||||
@@ -192,10 +198,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (itemsNotDownloaded.length > 1) {
|
if (itemsNotDownloaded.length > 1) {
|
||||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
const defaults = getDefaultPlaySettings(item, settings!);
|
||||||
item,
|
mediaSource = defaults.mediaSource;
|
||||||
settings!
|
audioIndex = defaults.audioIndex;
|
||||||
));
|
subtitleIndex = defaults.subtitleIndex;
|
||||||
|
// Keep using the selected bitrate for consistency across all downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
@@ -212,8 +219,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Something went wrong",
|
t("home.downloads.something_went_wrong"),
|
||||||
"Could not get stream url from Jellyfin"
|
t("home.downloads.could_not_get_stream_url_from_jellyfin")
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -330,7 +337,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-neutral-300">
|
<Text className="text-neutral-300">
|
||||||
{subtitle || `Download ${itemsNotDownloaded.length} items`}
|
{subtitle ||
|
||||||
|
t("item_card.download.download_x_item", {
|
||||||
|
item_count: itemsNotDownloaded.length,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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">
|
||||||
@@ -368,13 +378,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
onPress={acceptDownloadOptions}
|
onPress={acceptDownloadOptions}
|
||||||
color="purple"
|
color="purple"
|
||||||
>
|
>
|
||||||
Download
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<View className="opacity-70 text-center w-full flex items-center">
|
<View className="opacity-70 text-center w-full flex items-center">
|
||||||
<Text className="text-xs">
|
<Text className="text-xs">
|
||||||
{usingOptimizedServer
|
{usingOptimizedServer
|
||||||
? "Using optimized server"
|
? t("item_card.download.using_optimized_server")
|
||||||
: "Using default method"}
|
: t("item_card.download.using_default_method")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -388,10 +398,16 @@ export const DownloadSingleItem: React.FC<{
|
|||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}> = ({ item, size = "default" }) => {
|
}> = ({ item, size = "default" }) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
size={size}
|
size={size}
|
||||||
title="Download Episode"
|
title={
|
||||||
|
item.Type == "Episode"
|
||||||
|
? t("item_card.download.download_episode")
|
||||||
|
: t("item_card.download.download_movie")
|
||||||
|
}
|
||||||
subtitle={item.Name!}
|
subtitle={item.Name!}
|
||||||
items={[item]}
|
items={[item]}
|
||||||
MissingDownloadIconComponent={() => (
|
MissingDownloadIconComponent={() => (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|||||||
import { DownloadSingleItem } 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";
|
||||||
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
@@ -14,8 +15,8 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
|
|||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import {
|
import {
|
||||||
@@ -24,17 +25,16 @@ import {
|
|||||||
} 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 { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { Platform, 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 { AddToFavorites } from "./AddToFavorites";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -81,23 +81,31 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
useEffect(() => {
|
||||||
headerRight: () =>
|
navigation.setOptions({
|
||||||
item && (
|
headerRight: () =>
|
||||||
<View className="flex flex-row items-center space-x-2">
|
item && (
|
||||||
<Chromecast background="blur" width={22} height={22} />
|
<View className="flex flex-row items-center space-x-2">
|
||||||
{item.Type !== "Program" && (
|
<Chromecast.Chromecast
|
||||||
<View className="flex flex-row items-center space-x-2">
|
background="blur"
|
||||||
<DownloadSingleItem item={item} size="large" />
|
width={22}
|
||||||
<PlayedStatus item={item} />
|
height={22}
|
||||||
<AddToFavorites item={item} type="item" />
|
/>
|
||||||
</View>
|
{item.Type !== "Program" && (
|
||||||
)}
|
<View className="flex flex-row items-center space-x-2">
|
||||||
</View>
|
{!Platform.isTV && (
|
||||||
),
|
<DownloadSingleItem item={item} size="large" />
|
||||||
});
|
)}
|
||||||
}, [item]);
|
<PlayedStatus items={[item]} size="large" />
|
||||||
|
<AddToFavorites item={item} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [item]);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
@@ -111,37 +119,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
|
|
||||||
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;
|
if (!selectedOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -191,7 +168,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<View className="flex flex-col bg-transparent shrink">
|
<View className="flex flex-col bg-transparent shrink">
|
||||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||||
<ItemHeader item={item} className="mb-4" />
|
<ItemHeader item={item} className="mb-4" />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && !Platform.isTV && (
|
||||||
<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"
|
||||||
@@ -231,7 +208,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
selected={selectedOptions.audioIndex}
|
selected={selectedOptions.audioIndex}
|
||||||
/>
|
/>
|
||||||
<SubtitleTrackSelector
|
<SubtitleTrackSelector
|
||||||
isTranscoding={isTranscoding}
|
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -22,15 +24,16 @@ interface Props {
|
|||||||
|
|
||||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="px-4 mt-2 mb-4">
|
<View className="px-4 mt-2 mb-4">
|
||||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
|
||||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||||
<View className="flex flex-row space-x-2">
|
<View className="flex flex-row space-x-2">
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-purple-600">More details</Text>
|
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
@@ -52,14 +55,18 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
<BottomSheetScrollView>
|
<BottomSheetScrollView>
|
||||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||||
<View className="">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
<Text className="text-lg font-bold mb-4">
|
||||||
|
{t("item_card.video")}
|
||||||
|
</Text>
|
||||||
<View className="flex flex-row space-x-2">
|
<View className="flex flex-row space-x-2">
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
<Text className="text-lg font-bold mb-2">
|
||||||
|
{t("item_card.audio")}
|
||||||
|
</Text>
|
||||||
<AudioStreamInfo
|
<AudioStreamInfo
|
||||||
audioStreams={
|
audioStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
@@ -70,7 +77,9 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-2">Subtitles</Text>
|
<Text className="text-lg font-bold mb-2">
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</Text>
|
||||||
<SubtitleStreamInfo
|
<SubtitleStreamInfo
|
||||||
subtitleStreams={
|
subtitleStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
@@ -227,12 +236,3 @@ const formatFileSize = (bytes?: number | null) => {
|
|||||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
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];
|
|
||||||
};
|
|
||||||
|
|||||||
46
components/JellyfinServerDiscovery.tsx
Normal file
46
components/JellyfinServerDiscovery.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, Text, TouchableOpacity } from "react-native";
|
||||||
|
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { ListGroup } from "./list/ListGroup";
|
||||||
|
import { ListItem } from "./list/ListItem";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onServerSelect?: (server: { address: string; serverName?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
|
||||||
|
const { servers, isSearching, startDiscovery } = useJellyfinDiscovery();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mt-2">
|
||||||
|
<Button onPress={startDiscovery} color="black">
|
||||||
|
<Text className="text-white text-center">
|
||||||
|
{isSearching ? t("server.searching") : t("server.search_for_local_servers")}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{servers.length ? (
|
||||||
|
<ListGroup title={t("server.servers")} className="mt-4">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<ListItem
|
||||||
|
key={server.address}
|
||||||
|
onPress={() =>
|
||||||
|
onServerSelect?.({
|
||||||
|
address: server.address,
|
||||||
|
serverName: server.serverName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title={server.address}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JellyfinServerDiscovery;
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useEffect, 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";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -21,6 +20,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const selectedName = useMemo(
|
const selectedName = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||||
@@ -29,6 +29,29 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
[item, selected]
|
[item, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const commonPrefix = useMemo(() => {
|
||||||
|
const mediaSources = item.MediaSources || [];
|
||||||
|
if (!mediaSources.length) return "";
|
||||||
|
|
||||||
|
let commonPrefix = "";
|
||||||
|
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
|
||||||
|
const char = mediaSources[0].Name![i];
|
||||||
|
if (mediaSources.every((source) => source.Name![i] === char)) {
|
||||||
|
commonPrefix += char;
|
||||||
|
} else {
|
||||||
|
commonPrefix = commonPrefix.slice(0, -1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commonPrefix;
|
||||||
|
}, [item.MediaSources]);
|
||||||
|
|
||||||
|
const name = (name?: string | null) => {
|
||||||
|
return name?.replace(commonPrefix, "").toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
@@ -39,7 +62,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.video")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -63,9 +88,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
{`${name(source.Name)}`}
|
||||||
source.Size
|
|
||||||
)}`}
|
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
@@ -74,9 +97,3 @@ 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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
@@ -24,6 +25,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: actor } = useQuery({
|
const { data: actor } = useQuery({
|
||||||
queryKey: ["actor", actorId],
|
queryKey: ["actor", actorId],
|
||||||
@@ -76,7 +78,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="text-lg font-bold mb-2 px-4">
|
<Text className="text-lg font-bold mb-2 px-4">
|
||||||
More with {actor?.Name}
|
{t("item_card.more_with", {name: actor?.Name})}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { TouchableOpacity, View, ViewProps } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
@@ -14,12 +15,13 @@ export const OverviewText: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [limit, setLimit] = useState(characterLimit);
|
const [limit, setLimit] = useState(characterLimit);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="text-lg font-bold mb-2">Overview</Text>
|
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setLimit((prev) =>
|
setLimit((prev) =>
|
||||||
@@ -31,7 +33,7 @@ export const OverviewText: React.FC<Props> = ({
|
|||||||
<Text>{tc(text, limit)}</Text>
|
<Text>{tc(text, limit)}</Text>
|
||||||
{text.length > characterLimit && (
|
{text.length > characterLimit && (
|
||||||
<Text className="text-purple-600 mt-1">
|
<Text className="text-purple-600 mt-1">
|
||||||
{limit === characterLimit ? "Show more" : "Show less"}
|
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { type PropsWithChildren, type ReactElement } from "react";
|
import { type PropsWithChildren, type ReactElement } from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
@@ -13,6 +13,7 @@ interface Props extends ViewProps {
|
|||||||
logo?: ReactElement;
|
logo?: ReactElement;
|
||||||
episodePoster?: ReactElement;
|
episodePoster?: ReactElement;
|
||||||
headerHeight?: number;
|
headerHeight?: number;
|
||||||
|
onEndReached?: (() => void) | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||||
@@ -21,6 +22,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
episodePoster,
|
episodePoster,
|
||||||
headerHeight = 400,
|
headerHeight = 400,
|
||||||
logo,
|
logo,
|
||||||
|
onEndReached,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
@@ -47,6 +49,11 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
|
||||||
|
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1" {...props}>
|
<View className="flex-1" {...props}>
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
@@ -55,6 +62,10 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}}
|
}}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
|
onScroll={e => {
|
||||||
|
if (isCloseToBottom(e.nativeEvent))
|
||||||
|
onEndReached?.()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{logo && (
|
{logo && (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Platform, Pressable } from "react-native";
|
||||||
import { apiAtom, userAtom } 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 { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -31,7 +32,9 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
@@ -50,6 +53,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -67,17 +71,14 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string) => {
|
||||||
if (!bitrateValue) {
|
router.push(`/player/direct-player?${q}`);
|
||||||
router.push(`/player/direct-player?${q}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/player/transcoding-player?${q}`);
|
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
|
console.log("onPress");
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -93,7 +94,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
goToPlayer(queryString);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,101 +114,110 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
await CastContext.getPlayServicesState().then(async (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 {
|
||||||
// Get a new URL with the Chromecast device profile:
|
// Check if user wants H265 for Chromecast
|
||||||
const data = await getStreamUrl({
|
const enableH265 = settings.enableH265ForChromecast;
|
||||||
api,
|
|
||||||
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) {
|
// Get a new URL with the Chromecast device profile
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
try {
|
||||||
Alert.alert(
|
const data = await getStreamUrl({
|
||||||
"Client error",
|
api,
|
||||||
"Could not create stream for Chromecast"
|
item,
|
||||||
);
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||||
return;
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
}
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
client
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
.loadMedia({
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||||
mediaInfo: {
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
contentUrl: data?.url,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata:
|
|
||||||
item.Type === "Episode"
|
|
||||||
? {
|
|
||||||
type: "tvShow",
|
|
||||||
title: item.Name || "",
|
|
||||||
episodeNumber: item.IndexNumber || 0,
|
|
||||||
seasonNumber: item.ParentIndexNumber || 0,
|
|
||||||
seriesTitle: item.SeriesName || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getParentBackdropImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: item.Type === "Movie"
|
|
||||||
? {
|
|
||||||
type: "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "generic",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// state is already set when reopening current media, so skip it here.
|
|
||||||
if (isOpeningCurrentlyPlayingMedia) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CastContext.showExpandedControls();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("URL: ", data?.url, enableH265);
|
||||||
|
|
||||||
|
if (!data?.url) {
|
||||||
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("player.could_not_create_stream_for_chromecast")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.loadMedia({
|
||||||
|
mediaInfo: {
|
||||||
|
contentUrl: data?.url,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
metadata:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? {
|
||||||
|
type: "tvShow",
|
||||||
|
title: item.Name || "",
|
||||||
|
episodeNumber: item.IndexNumber || 0,
|
||||||
|
seasonNumber: item.ParentIndexNumber || 0,
|
||||||
|
seriesTitle: item.SeriesName || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getParentBackdropImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: item.Type === "Movie"
|
||||||
|
? {
|
||||||
|
type: "movie",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "generic",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: 0,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// state is already set when reopening current media, so skip it here.
|
||||||
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CastContext.showExpandedControls();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
goToPlayer(queryString);
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
@@ -316,75 +326,62 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
disabled={!item}
|
||||||
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">
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
animatedPrimaryStyle,
|
|
||||||
animatedWidthStyle,
|
|
||||||
{
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
style={[
|
||||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
animatedPrimaryStyle,
|
||||||
|
animatedWidthStyle,
|
||||||
|
{
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<View
|
</View>
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
<Animated.View
|
||||||
borderColor: colorAtom.primary,
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
borderStyle: "solid",
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
}}
|
/>
|
||||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
<View
|
||||||
>
|
style={{
|
||||||
<View className="flex flex-row items-center space-x-2">
|
borderWidth: 1,
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
borderColor: colorAtom.primary,
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
borderStyle: "solid",
|
||||||
</Animated.Text>
|
}}
|
||||||
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name="play-circle" size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{client && (
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name="play-circle" size={24} />
|
<Feather name="cast" size={22} />
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{client && (
|
)}
|
||||||
<Animated.Text style={animatedTextStyle}>
|
{!client && settings?.openInVLC && (
|
||||||
<Feather name="cast" size={22} />
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<CastButton tintColor="transparent" />
|
<MaterialCommunityIcons
|
||||||
</Animated.Text>
|
name="vlc"
|
||||||
)}
|
size={18}
|
||||||
{!client && settings?.openInVLC && (
|
color={animatedTextStyle.color}
|
||||||
<Animated.Text style={animatedTextStyle}>
|
/>
|
||||||
<MaterialCommunityIcons
|
</Animated.Text>
|
||||||
name="vlc"
|
)}
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
{/* <View className="mt-2 flex flex-row items-center">
|
</TouchableOpacity>
|
||||||
<Ionicons
|
|
||||||
name="information-circle"
|
|
||||||
size={12}
|
|
||||||
className=""
|
|
||||||
color={"#9BA1A6"}
|
|
||||||
/>
|
|
||||||
<Text className="text-neutral-500 ml-1">
|
|
||||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
|
||||||
</Text>
|
|
||||||
</View> */}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
226
components/PlayButton.tv.tsx
Normal file
226
components/PlayButton.tv.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
interpolate,
|
||||||
|
interpolateColor,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { SelectedOptions } from "./ItemContent";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_DURATION = 500;
|
||||||
|
const MIN_PLAYBACK_WIDTH = 15;
|
||||||
|
|
||||||
|
export const PlayButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const startWidth = useSharedValue(0);
|
||||||
|
const targetWidth = useSharedValue(0);
|
||||||
|
const endColor = useSharedValue(colorAtom);
|
||||||
|
const startColor = useSharedValue(colorAtom);
|
||||||
|
const widthProgress = useSharedValue(0);
|
||||||
|
const colorChangeProgress = useSharedValue(0);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const goToPlayer = useCallback(
|
||||||
|
(q: string) => {
|
||||||
|
router.push(`/player/direct-player?${q}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
console.log("onpress");
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
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();
|
||||||
|
goToPlayer(queryString);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
|
const userData = item.UserData;
|
||||||
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
? Math.max(
|
||||||
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||||
|
MIN_PLAYBACK_WIDTH
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => derivedTargetWidth.value,
|
||||||
|
(newWidth) => {
|
||||||
|
targetWidth.value = newWidth;
|
||||||
|
widthProgress.value = 0;
|
||||||
|
widthProgress.value = withTiming(1, {
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => colorAtom,
|
||||||
|
(newColor) => {
|
||||||
|
endColor.value = newColor;
|
||||||
|
colorChangeProgress.value = 0;
|
||||||
|
colorChangeProgress.value = withTiming(1, {
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[colorAtom]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout_2 = setTimeout(() => {
|
||||||
|
startColor.value = colorAtom;
|
||||||
|
startWidth.value = targetWidth.value;
|
||||||
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout_2);
|
||||||
|
};
|
||||||
|
}, [colorAtom, item]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ANIMATED STYLES
|
||||||
|
*/
|
||||||
|
const animatedAverageStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.primary, endColor.value.primary]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.primary, endColor.value.primary]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedWidthStyle = useAnimatedStyle(() => ({
|
||||||
|
width: `${interpolate(
|
||||||
|
widthProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startWidth.value, targetWidth.value]
|
||||||
|
)}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||||
|
color: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.text, endColor.value.text]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
/**
|
||||||
|
* *********************
|
||||||
|
*/
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityLabel="Play button"
|
||||||
|
accessibilityHint="Tap to play the media"
|
||||||
|
onPress={onPress}
|
||||||
|
className={`relative`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedPrimaryStyle,
|
||||||
|
animatedWidthStyle,
|
||||||
|
{
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorAtom.primary,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name="play-circle" size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{settings?.openInVLC && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="vlc"
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,16 +6,19 @@ import { View, ViewProps } from "react-native";
|
|||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
items: BaseItemDto[];
|
||||||
|
size?: "default" | "large";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
queryClient.invalidateQueries({
|
items.forEach((item) => {
|
||||||
queryKey: ["item", item.Id],
|
queryClient.invalidateQueries({
|
||||||
});
|
queryKey: ["item", item.Id],
|
||||||
|
});
|
||||||
|
})
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["resumeItems"],
|
queryKey: ["resumeItems"],
|
||||||
});
|
});
|
||||||
@@ -39,15 +42,20 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
|
|
||||||
|
const markAsPlayedStatus = useMarkAsPlayed(items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
fillColor={item.UserData?.Played ? "primary" : undefined}
|
fillColor={allPlayed ? "primary" : undefined}
|
||||||
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
onPress={async () => {
|
||||||
size="large"
|
console.log(allPlayed);
|
||||||
|
await markAsPlayedStatus(!allPlayed)
|
||||||
|
}}
|
||||||
|
size={props.size}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { View } from "react-native";
|
|||||||
import { useMMKVString } from "react-native-mmkv";
|
import { useMMKVString } from "react-native-mmkv";
|
||||||
import { ListGroup } from "./list/ListGroup";
|
import { ListGroup } from "./list/ListGroup";
|
||||||
import { ListItem } from "./list/ListItem";
|
import { ListItem } from "./list/ListItem";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -22,11 +23,13 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
return JSON.parse(_previousServers || "[]") as Server[];
|
return JSON.parse(_previousServers || "[]") as Server[];
|
||||||
}, [_previousServers]);
|
}, [_previousServers]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!previousServers.length) return null;
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<ListGroup title="previous servers" className="mt-4">
|
<ListGroup title={t("server.previous_servers")} className="mt-4">
|
||||||
{previousServers.map((s) => (
|
{previousServers.map((s) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={s.address}
|
key={s.address}
|
||||||
@@ -39,7 +42,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
setPreviousServers("[]");
|
setPreviousServers("[]");
|
||||||
}}
|
}}
|
||||||
title={"Clear"}
|
title={t("server.clear_button")}
|
||||||
textColor="red"
|
textColor="red"
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ItemCardText } from "./ItemCardText";
|
|||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
||||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SimilarItemsProps extends ViewProps {
|
interface SimilarItemsProps extends ViewProps {
|
||||||
itemId?: string | null;
|
itemId?: string | null;
|
||||||
@@ -23,6 +24,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
|
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
|
||||||
queryKey: ["similarItems", itemId],
|
queryKey: ["similarItems", itemId],
|
||||||
@@ -47,12 +49,12 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
<Text className="px-4 text-lg font-bold mb-2">{t("item_card.similar_items")}</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={movies}
|
data={movies}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
height={247}
|
height={247}
|
||||||
noItemsText="No similar items found"
|
noItemsText={t("item_card.no_similar_items_found")}
|
||||||
renderItem={(item: BaseItemDto, idx: number) => (
|
renderItem={(item: BaseItemDto, idx: number) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={idx}
|
key={idx}
|
||||||
|
|||||||
@@ -2,40 +2,35 @@ 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 { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
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 | undefined;
|
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
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
|
}, [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),
|
||||||
[subtitleStreams, selected]
|
[subtitleStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams?.length === 0) return null;
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -48,12 +43,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col " {...props}>
|
<View className="flex flex-col " {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
|
<Text numberOfLines={1} className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text className=" ">
|
<Text className=" ">
|
||||||
{selectedSubtitleSteam
|
{selectedSubtitleSteam
|
||||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||||
: "None"}
|
: t("item_card.none")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
118
components/common/Dropdown.tsx
Normal file
118
components/common/Dropdown.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import React, {
|
||||||
|
PropsWithChildren,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
data: T[];
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholderText?: string;
|
||||||
|
keyExtractor: (item: T) => string;
|
||||||
|
titleExtractor: (item: T) => string | undefined;
|
||||||
|
title: string | ReactNode;
|
||||||
|
label: string;
|
||||||
|
onSelected: (...item: T[]) => void;
|
||||||
|
multi?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown = <T extends unknown>({
|
||||||
|
data,
|
||||||
|
disabled,
|
||||||
|
placeholderText,
|
||||||
|
keyExtractor,
|
||||||
|
titleExtractor,
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
onSelected,
|
||||||
|
multi = false,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
const [selected, setSelected] = useState<T[]>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected !== undefined) {
|
||||||
|
onSelected(...selected);
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{typeof title === "string" ? (
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="opacity-50 mb-1 text-xs">{title}</Text>
|
||||||
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
|
{selected?.length !== undefined
|
||||||
|
? selected.map(titleExtractor).join(",")
|
||||||
|
: placeholderText}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>{title}</>
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={false}
|
||||||
|
side="bottom"
|
||||||
|
align="center"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
sideOffset={0}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
||||||
|
{data.map((item, idx) =>
|
||||||
|
multi ? (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
value={
|
||||||
|
selected?.some((s) => keyExtractor(s) == keyExtractor(item))
|
||||||
|
? "on"
|
||||||
|
: "off"
|
||||||
|
}
|
||||||
|
key={keyExtractor(item)}
|
||||||
|
onValueChange={(next, previous) =>
|
||||||
|
setSelected((p) => {
|
||||||
|
const prev = p || [];
|
||||||
|
if (next == "on") {
|
||||||
|
return [...prev, item];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev.filter(
|
||||||
|
(p) => keyExtractor(p) !== keyExtractor(item)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{titleExtractor(item)}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={keyExtractor(item)}
|
||||||
|
onSelect={() => setSelected([item])}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{titleExtractor(item)}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dropdown;
|
||||||
@@ -15,6 +15,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
import { t } from "i18next";
|
||||||
|
|
||||||
interface HorizontalScrollProps
|
interface HorizontalScrollProps
|
||||||
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
|
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
|
||||||
@@ -136,7 +137,7 @@ export function InfiniteHorizontalScroll({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
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">No data available</Text>
|
<Text className="text-center text-gray-500">{t("item_card.no_data_available")}</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TextInput, TextInputProps } from "react-native";
|
import {Platform, TextInput, TextInputProps, TouchableOpacity} from "react-native";
|
||||||
export function Input(props: TextInputProps) {
|
export function Input(props: TextInputProps) {
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
const inputRef = React.useRef<TextInput>(null);
|
const inputRef = React.useRef<TextInput>(null);
|
||||||
|
|
||||||
return (
|
return Platform.isTV ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onFocus={() => inputRef?.current?.focus?.()}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
className="p-4 rounded-xl bg-neutral-900"
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[{ color: "white" }, style]}
|
||||||
|
placeholderTextColor={"#9CA3AF"}
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="p-4 rounded-xl bg-neutral-900"
|
className="p-4 rounded-xl bg-neutral-900"
|
||||||
@@ -14,5 +28,5 @@ export function Input(props: TextInputProps) {
|
|||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {useRouter, useSegments} from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import React, {PropsWithChildren, useCallback, useMemo} from "react";
|
import React, { PropsWithChildren, useCallback, useMemo } from "react";
|
||||||
import {TouchableOpacity, TouchableOpacityProps} from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
import * as ContextMenu from "@/components/ContextMenu";
|
||||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
import {
|
||||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
result: MovieResult | TvResult;
|
result: MovieResult | TvResult;
|
||||||
@@ -26,26 +29,27 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr()
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const autoApprove = useMemo(() => {
|
const autoApprove = useMemo(() => {
|
||||||
return jellyseerrUser && hasPermission(
|
return (
|
||||||
Permission.AUTO_APPROVE,
|
jellyseerrUser &&
|
||||||
jellyseerrUser.permissions,
|
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
||||||
{type: 'or'}
|
type: "or",
|
||||||
)
|
})
|
||||||
}, [jellyseerrApi, jellyseerrUser])
|
);
|
||||||
|
}, [jellyseerrApi, jellyseerrUser]);
|
||||||
|
|
||||||
const request = useCallback(() =>
|
const request = useCallback(
|
||||||
|
() =>
|
||||||
requestMedia(mediaTitle, {
|
requestMedia(mediaTitle, {
|
||||||
mediaId: result.id,
|
mediaId: result.id,
|
||||||
mediaType: result.mediaType
|
mediaType: result.mediaType,
|
||||||
}
|
}),
|
||||||
),
|
|
||||||
[jellyseerrApi, result]
|
[jellyseerrApi, result]
|
||||||
)
|
);
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
@@ -55,7 +59,16 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
|
router.push({
|
||||||
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
|
params: {
|
||||||
|
...result,
|
||||||
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest,
|
||||||
|
posterSrc,
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -71,31 +84,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
>
|
>
|
||||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||||
{canRequest && result.mediaType === MediaType.MOVIE && (
|
{canRequest && result.mediaType === MediaType.MOVIE && (
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key="item-1"
|
key="item-1"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (autoApprove) {
|
if (autoApprove) {
|
||||||
request()
|
request();
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
shouldDismissMenuOnSelect
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle key="item-1-title">
|
||||||
|
Request
|
||||||
|
</ContextMenu.ItemTitle>
|
||||||
|
<ContextMenu.ItemIcon
|
||||||
|
ios={{
|
||||||
|
name: "arrow.down.to.line",
|
||||||
|
pointSize: 18,
|
||||||
|
weight: "semibold",
|
||||||
|
scale: "medium",
|
||||||
|
hierarchicalColor: {
|
||||||
|
dark: "purple",
|
||||||
|
light: "purple",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
shouldDismissMenuOnSelect
|
androidIconName="download"
|
||||||
>
|
/>
|
||||||
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.ItemIcon
|
)}
|
||||||
ios={{
|
|
||||||
name: "arrow.down.to.line",
|
|
||||||
pointSize: 18,
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "purple",
|
|
||||||
light: "purple",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
androidIconName="download"
|
|
||||||
/>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
)}
|
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
</ContextMenu.Root>
|
</ContextMenu.Root>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TextProps } from "react-native";
|
import { Platform, TextProps } from "react-native";
|
||||||
import { UITextView } from "react-native-uitextview";
|
import { UITextView } from "react-native-uitextview";
|
||||||
|
import { Text as RNText } from "react-native";
|
||||||
export function Text(
|
export function Text(
|
||||||
props: TextProps & {
|
props: TextProps & {
|
||||||
uiTextView?: boolean;
|
uiTextView?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
|
if (Platform.isTV)
|
||||||
return (
|
return (
|
||||||
<UITextView
|
<RNText
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<UITextView
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[{ color: "white" }, style]}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemPerson,
|
BaseItemPerson,
|
||||||
@@ -6,9 +7,7 @@ import {
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren, useCallback } from "react";
|
import { PropsWithChildren, useCallback } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -26,18 +25,6 @@ export const itemRouter = (
|
|||||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "MusicAlbum") {
|
|
||||||
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Audio") {
|
|
||||||
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "MusicArtist") {
|
|
||||||
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Person" || item.Type === "Actor") {
|
if (item.Type === "Person" || item.Type === "Actor") {
|
||||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||||
}
|
}
|
||||||
@@ -69,15 +56,15 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
|
if (!(item.Type === "Movie" || item.Type === "Episode" || item.Type === "Series")) return;
|
||||||
|
const options = ["Mark as Played", "Mark as Not Played", isFavorite ? "Unmark as Favorite" : "Mark as Favorite", "Cancel"];
|
||||||
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
|
const cancelButtonIndex = 3;
|
||||||
const cancelButtonIndex = 2;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
@@ -87,14 +74,14 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
async (selectedIndex) => {
|
async (selectedIndex) => {
|
||||||
if (selectedIndex === 0) {
|
if (selectedIndex === 0) {
|
||||||
await markAsPlayedStatus(true);
|
await markAsPlayedStatus(true);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
} else if (selectedIndex === 1) {
|
} else if (selectedIndex === 1) {
|
||||||
await markAsPlayedStatus(false);
|
await markAsPlayedStatus(false);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
} else if (selectedIndex === 2) {
|
||||||
|
toggleFavorite()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [showActionSheetWithOptions, markAsPlayedStatus]);
|
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
from === "(home)" ||
|
from === "(home)" ||
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -19,9 +20,12 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Image } from "expo-image";
|
const BackGroundDownloader = !Platform.isTV
|
||||||
import { useMemo } from "react";
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
import { storage } from "@/utils/mmkv";
|
: null;
|
||||||
|
const FFmpegKitProvider = !Platform.isTV
|
||||||
|
? require("ffmpeg-kit-react-native")
|
||||||
|
: null;
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -30,16 +34,22 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
|||||||
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">
|
||||||
<Text className="text-lg font-bold">Active download</Text>
|
<Text className="text-lg font-bold">
|
||||||
<Text className="opacity-50">No active downloads</Text>
|
{t("home.downloads.active_download")}
|
||||||
|
</Text>
|
||||||
|
<Text className="opacity-50">
|
||||||
|
{t("home.downloads.no_active_downloads")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
<Text className="text-lg font-bold mb-2">
|
||||||
|
{t("home.downloads.active_downloads")}
|
||||||
|
</Text>
|
||||||
<View className="space-y-2">
|
<View className="space-y-2">
|
||||||
{processes?.map((p) => (
|
{processes?.map((p: JobStatus) => (
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
<DownloadCard key={p.item.Id} process={p} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -62,9 +72,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
if (!process) throw new Error("No active download");
|
if (!process) throw new Error("No active download");
|
||||||
|
|
||||||
if (settings?.downloadMethod === "optimized") {
|
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||||
try {
|
try {
|
||||||
const tasks = await checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.id === id) {
|
if (task.id === id) {
|
||||||
task.stop();
|
task.stop();
|
||||||
@@ -77,16 +87,18 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FFmpegKit.cancel(Number(id));
|
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
||||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
setProcesses((prev: any[]) =>
|
||||||
|
prev.filter((p: { id: string }) => p.id !== id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Download canceled");
|
toast.success(t("home.downloads.toasts.download_cancelled"));
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Could not cancel download");
|
toast.error(t("home.downloads.toasts.could_not_cancel_download"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,7 +165,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
||||||
)}
|
)}
|
||||||
{eta(process) && (
|
{eta(process) && (
|
||||||
<Text className="text-xs">ETA {eta(process)}</Text>
|
<Text className="text-xs">
|
||||||
|
{t("home.downloads.eta", { eta: eta(process) })}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Input } from "../common/Input";
|
import { Input } from "../common/Input";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props<T> extends ViewProps {
|
interface Props<T> extends ViewProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -76,6 +77,7 @@ export const FilterSheet = <T,>({
|
|||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["80%"], []);
|
const snapPoints = useMemo(() => ["80%"], []);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [data, setData] = useState<T[]>([]);
|
const [data, setData] = useState<T[]>([]);
|
||||||
const [offset, setOffset] = useState<number>(0);
|
const [offset, setOffset] = useState<number>(0);
|
||||||
@@ -153,10 +155,10 @@ export const FilterSheet = <T,>({
|
|||||||
>
|
>
|
||||||
<View className="px-4 mt-2 mb-8">
|
<View className="px-4 mt-2 mb-8">
|
||||||
<Text className="font-bold text-2xl">{title}</Text>
|
<Text className="font-bold text-2xl">{title}</Text>
|
||||||
<Text className="mb-2 text-neutral-500">{_data?.length} items</Text>
|
<Text className="mb-2 text-neutral-500">{t("search.items", {count: _data?.length})}</Text>
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search..."
|
placeholder={t("search.search")}
|
||||||
className="my-2"
|
className="my-2"
|
||||||
value={search}
|
value={search}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { View } from "react-native";
|
|||||||
import { ScrollingCollectionList } from "./ScrollingCollectionList";
|
import { ScrollingCollectionList } from "./ScrollingCollectionList";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { t } from "i18next";
|
||||||
|
|
||||||
export const Favorites = () => {
|
export const Favorites = () => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -54,64 +55,44 @@ export const Favorites = () => {
|
|||||||
() => fetchFavoritesByType("Playlist"),
|
() => fetchFavoritesByType("Playlist"),
|
||||||
[fetchFavoritesByType]
|
[fetchFavoritesByType]
|
||||||
);
|
);
|
||||||
const fetchFavoriteMusicAlbum = useCallback(
|
|
||||||
() => fetchFavoritesByType("MusicAlbum"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
const fetchFavoriteAudio = useCallback(
|
|
||||||
() => fetchFavoritesByType("Audio"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-co gap-y-4">
|
<View className="flex flex-co gap-y-4">
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryFn={fetchFavoriteSeries}
|
queryFn={fetchFavoriteSeries}
|
||||||
queryKey={["home", "favorites", "series"]}
|
queryKey={["home", "favorites", "series"]}
|
||||||
title="Series"
|
title={t("favorites.series")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryFn={fetchFavoriteMovies}
|
queryFn={fetchFavoriteMovies}
|
||||||
queryKey={["home", "favorites", "movies"]}
|
queryKey={["home", "favorites", "movies"]}
|
||||||
title="Movies"
|
title={t("favorites.movies")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryFn={fetchFavoriteEpisodes}
|
queryFn={fetchFavoriteEpisodes}
|
||||||
queryKey={["home", "favorites", "episodes"]}
|
queryKey={["home", "favorites", "episodes"]}
|
||||||
title="Episodes"
|
title={t("favorites.episodes")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryFn={fetchFavoriteVideos}
|
queryFn={fetchFavoriteVideos}
|
||||||
queryKey={["home", "favorites", "videos"]}
|
queryKey={["home", "favorites", "videos"]}
|
||||||
title="Videos"
|
title={t("favorites.videos")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryFn={fetchFavoriteBoxsets}
|
queryFn={fetchFavoriteBoxsets}
|
||||||
queryKey={["home", "favorites", "boxsets"]}
|
queryKey={["home", "favorites", "boxsets"]}
|
||||||
title="Boxsets"
|
title={t("favorites.boxsets")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryFn={fetchFavoritePlaylists}
|
queryFn={fetchFavoritePlaylists}
|
||||||
queryKey={["home", "favorites", "playlists"]}
|
queryKey={["home", "favorites", "playlists"]}
|
||||||
title="Playlists"
|
title={t("favorites.playlists")}
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteMusicAlbum}
|
|
||||||
queryKey={["home", "favorites", "musicAlbums"]}
|
|
||||||
title="Music Albums"
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteAudio}
|
|
||||||
queryKey={["home", "favorites", "audio"]}
|
|
||||||
title="Audio"
|
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
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 { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
@@ -6,9 +7,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
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 { useRouter, useSegments } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
|
import { Dimensions, View, ViewProps } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -18,11 +21,7 @@ import Carousel, {
|
|||||||
ICarouselInstance,
|
ICarouselInstance,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from "react-native-reanimated-carousel";
|
} from "react-native-reanimated-carousel";
|
||||||
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { itemRouter } from "../common/TouchableItemRouter";
|
||||||
import { Loader } from "../Loader";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import { useRouter, useSegments } from "expo-router";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -162,7 +161,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const tap = Gesture.Tap()
|
const tap = Gesture.Tap()
|
||||||
.maxDuration(2000)
|
.maxDuration(2000)
|
||||||
.onBegin(() => {
|
.onBegin(() => {
|
||||||
opacity.value = withTiming(0.5, { duration: 100 });
|
opacity.value = withTiming(0.8, { duration: 100 });
|
||||||
})
|
})
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
runOnJS(handleRoute)();
|
runOnJS(handleRoute)();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
|||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import SeriesPoster from "../posters/SeriesPoster";
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -39,9 +40,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (disabled || !title) return null;
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (hideIfEmpty === true && data?.length === 0) return null;
|
if (hideIfEmpty === true && data?.length === 0) return null;
|
||||||
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
@@ -50,7 +52,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{isLoading === false && data?.length === 0 && (
|
{isLoading === false && data?.length === 0 && (
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<Text className="text-neutral-500">No items</Text>
|
<Text className="text-neutral-500">{t("home.no_items")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -104,7 +106,12 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
{item.Type === "Movie" && orientation === "vertical" && (
|
{item.Type === "Movie" && orientation === "vertical" && (
|
||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
)}
|
)}
|
||||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
{item.Type === "Series" && orientation === "vertical" && (
|
||||||
|
<SeriesPoster item={item} />
|
||||||
|
)}
|
||||||
|
{item.Type === "Series" && orientation === "horizontal" && (
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
)}
|
||||||
{item.Type === "Program" && (
|
{item.Type === "Program" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {TouchableOpacity, View} from "react-native";
|
import {TouchableOpacity, View} from "react-native";
|
||||||
import {Text} from "@/components/common/Text";
|
import {Text} from "@/components/common/Text";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
interface StepperProps {
|
interface StepperProps {
|
||||||
value: number,
|
value: number,
|
||||||
|
disabled?: boolean,
|
||||||
step: number,
|
step: number,
|
||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
@@ -12,6 +14,7 @@ interface StepperProps {
|
|||||||
|
|
||||||
export const Stepper: React.FC<StepperProps> = ({
|
export const Stepper: React.FC<StepperProps> = ({
|
||||||
value,
|
value,
|
||||||
|
disabled,
|
||||||
step,
|
step,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
@@ -19,7 +22,11 @@ export const Stepper: React.FC<StepperProps> = ({
|
|||||||
appendValue
|
appendValue
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center">
|
<DisabledSetting
|
||||||
|
disabled={disabled === true}
|
||||||
|
showText={false}
|
||||||
|
className="flex flex-row items-center"
|
||||||
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => onUpdate(Math.max(min, value - step))}
|
onPress={() => onUpdate(Math.max(min, value - step))}
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
@@ -39,6 +46,6 @@ export const Stepper: React.FC<StepperProps> = ({
|
|||||||
>
|
>
|
||||||
<Text>+</Text>
|
<Text>+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user