mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-19 01:28:06 +00:00
Compare commits
252 Commits
feat/atv
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
aad6093852 | ||
|
|
c553cff9d1 | ||
|
|
dcd458bd3d | ||
|
|
05dc61d17d | ||
|
|
e4de11127f | ||
|
|
2dc49735f4 | ||
|
|
0ebacd4bd3 | ||
|
|
14c8c1aaed | ||
|
|
2da774272d | ||
|
|
ef42207174 | ||
|
|
efa5638b12 | ||
|
|
c63cea891d | ||
|
|
4e80f58823 | ||
|
|
cfe39d504c | ||
|
|
cf43d1a657 | ||
|
|
cbe3b18226 | ||
|
|
b637a0f7d2 | ||
|
|
a0ce7cc6d0 | ||
|
|
a640df30bc | ||
|
|
062e6e6c23 | ||
|
|
d709e3b13e | ||
|
|
b232bebd73 | ||
|
|
90ef8ef6f9 | ||
|
|
0df6b8e2a0 | ||
|
|
f48b26076d | ||
|
|
c86a8438e5 | ||
|
|
faa2baae68 | ||
|
|
ed42371353 | ||
|
|
24277135a8 | ||
|
|
23d9cd36d1 | ||
|
|
b243524a7d | ||
|
|
8288682e68 | ||
|
|
58ec915699 | ||
|
|
480abb216d | ||
|
|
249109a94e | ||
|
|
eb7fa93f9b | ||
|
|
e8fd322d30 | ||
|
|
cad03a3566 | ||
|
|
9baa4063bd | ||
|
|
41db34ed8e | ||
|
|
5aba66ce05 | ||
|
|
79407ccd70 | ||
|
|
9a93b3b3bb | ||
|
|
2b846a1aca | ||
|
|
55d61172f4 | ||
|
|
57173a62dc | ||
|
|
78f65be09d | ||
|
|
293a9517a5 | ||
|
|
38b6215046 | ||
|
|
fc4a11d916 | ||
|
|
cf2beb8299 | ||
|
|
49d157a95a | ||
|
|
9692c173ae | ||
|
|
a297ac4843 | ||
|
|
a061f9f480 | ||
|
|
0fb6f2fb30 | ||
|
|
0773f773ba | ||
|
|
39bb3a9370 | ||
|
|
b79e534692 | ||
|
|
e9336e9a67 | ||
|
|
adfde1a7cd | ||
|
|
cab6257fb2 | ||
|
|
3f0f0090af | ||
|
|
b278632581 | ||
|
|
5ee1a9cabb | ||
|
|
2169bea031 | ||
|
|
95cf252349 | ||
|
|
8470cbe8d5 | ||
|
|
636a27246f | ||
|
|
a488c68633 | ||
|
|
7342b7eb92 | ||
|
|
8370519758 | ||
|
|
85e21edbf1 | ||
|
|
8d4115f5a0 | ||
|
|
c5d7a6729b | ||
|
|
db4046267f | ||
|
|
1e869a2c2f | ||
|
|
b6502c042a | ||
|
|
b506871c46 | ||
|
|
734678b1d5 | ||
|
|
68e98bbb94 | ||
|
|
d84ed558f3 | ||
|
|
ad39e8e10a | ||
|
|
29bba04fdd | ||
|
|
5a24957e88 | ||
|
|
39f2735756 | ||
|
|
53ea1cc899 | ||
|
|
5dc86d4765 | ||
|
|
d13731c28f | ||
|
|
459ca3245b | ||
|
|
0d1fb87284 | ||
|
|
7f0446b85f | ||
|
|
11fbe19f80 | ||
|
|
495742c52c | ||
|
|
5c97b85492 | ||
|
|
894305e126 | ||
|
|
e60cec69f8 | ||
|
|
7bc1c22770 | ||
|
|
e86dab5613 | ||
|
|
eeb803223c | ||
|
|
1a43f7ef1b | ||
|
|
f4624bdc25 | ||
|
|
3c5f2b4079 | ||
|
|
955190a9cc | ||
|
|
e1e4f4833c | ||
|
|
3b987646a6 | ||
|
|
ed993d07ce | ||
|
|
0e574ea18d | ||
|
|
dc9008f31c | ||
|
|
1a5fcdcb10 | ||
|
|
62b00837ec | ||
|
|
0fc48497d0 | ||
|
|
7e12136211 | ||
|
|
7639de153b | ||
|
|
ea3cc18b3c | ||
|
|
c9fb52086e | ||
|
|
878edc6909 | ||
|
|
74f0aca517 | ||
|
|
60bb3b905d | ||
|
|
fdde5fb56c | ||
|
|
49ae9c6f57 | ||
|
|
2254adb8d6 | ||
|
|
d4c722aeac | ||
|
|
eefcfb8be5 | ||
|
|
4af2712cc0 | ||
|
|
958b870bf0 | ||
|
|
ce7e1b255f | ||
|
|
acae4b4544 | ||
|
|
f7bbb20c38 | ||
|
|
2c655b9482 | ||
|
|
b8dbce6bf2 | ||
|
|
730823c520 | ||
|
|
77f14a7d5b | ||
|
|
07c7cb7ab5 | ||
|
|
5333d53d61 | ||
|
|
82e50b9ba3 | ||
|
|
663605b9e8 | ||
|
|
00847c8d3d | ||
|
|
f20ad67186 | ||
|
|
e23387a384 | ||
|
|
bb141cad57 | ||
|
|
e833b4bc68 | ||
|
|
34fc26ed18 | ||
|
|
91527b83dd | ||
|
|
14138151a3 | ||
|
|
6c2bfe2a45 | ||
|
|
996cd36a9e | ||
|
|
6aa2e00d93 | ||
|
|
344e0932dc | ||
|
|
eaffffb2f0 | ||
|
|
f6c0513d2d | ||
|
|
013f064280 | ||
|
|
cd2c3f359e | ||
|
|
123c6bba05 | ||
|
|
a1ea926342 | ||
|
|
6a17ac02af | ||
|
|
815be2a175 | ||
|
|
ece3bc001f | ||
|
|
27609e7789 | ||
|
|
40b8410390 | ||
|
|
347f196a6a | ||
|
|
468f58e531 | ||
|
|
a994868be4 | ||
|
|
723233381c | ||
|
|
602de34824 | ||
|
|
9b1f2a98e5 | ||
|
|
946de97580 | ||
|
|
f2eadabf6a | ||
|
|
373d83a0d5 | ||
|
|
2c0ba18b49 | ||
|
|
3e8e8e1163 | ||
|
|
fe9c73a8f0 | ||
|
|
4f62391027 | ||
|
|
53b5fdda87 | ||
|
|
c0b71eb73d | ||
|
|
9b4590c876 | ||
|
|
4b18bad3bc | ||
|
|
752cb1cdc6 |
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -4,9 +4,7 @@ title: "[Bug]: "
|
|||||||
labels:
|
labels:
|
||||||
- ["❌ bug"]
|
- ["❌ bug"]
|
||||||
projects:
|
projects:
|
||||||
- ["fredrikburmester/5"]
|
- ["streamyfin/3"]
|
||||||
assignees:
|
|
||||||
- fredrikburmester
|
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -45,6 +43,8 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.25.0
|
||||||
|
- 0.24.0
|
||||||
- 0.23.0
|
- 0.23.0
|
||||||
- 0.22.0
|
- 0.22.0
|
||||||
- 0.21.0
|
- 0.21.0
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -4,7 +4,8 @@ about: Suggest an idea for this project
|
|||||||
title: ''
|
title: ''
|
||||||
labels: '✨ enhancement'
|
labels: '✨ enhancement'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
projects:
|
||||||
|
- streamyfin/3
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
|
|||||||
49
.github/workflows/build-ios.yaml
vendored
Normal file
49
.github/workflows/build-ios.yaml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Automatic Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-15
|
||||||
|
name: Build IOS
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
name: Check out repository
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
- run: |
|
||||||
|
bun i && bun run submodule-reload
|
||||||
|
npx expo prebuild
|
||||||
|
- uses: sparkfabrik/ios-build-action@v2.3.0
|
||||||
|
with:
|
||||||
|
upload-to-testflight: false
|
||||||
|
increment-build-number: false
|
||||||
|
build-pods: true
|
||||||
|
pods-path: "ios/Podfile"
|
||||||
|
configuration: Release
|
||||||
|
# Change later to app-store if wanted
|
||||||
|
export-method: appstore
|
||||||
|
#export-method: ad-hoc
|
||||||
|
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
|
||||||
|
project-path: "ios/Streamyfin.xcodeproj"
|
||||||
|
scheme: Streamyfin
|
||||||
|
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
|
||||||
|
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
||||||
|
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
|
||||||
|
team-id: ${{ secrets.TEAM_ID }}
|
||||||
|
team-name: ${{ secrets.TEAM_NAME }}
|
||||||
|
#match-password: ${{ secrets.MATCH_PASSWORD }}
|
||||||
|
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
|
||||||
|
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||||
|
#match-build-type: "appstore"
|
||||||
|
#browserstack-upload: true
|
||||||
|
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||||
|
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||||
|
#fastlane-env: stage
|
||||||
|
ios-app-id: com.stetsed.teststreamyfin
|
||||||
|
output-path: build-${{ github.sha }}.ipa
|
||||||
18
.github/workflows/notification.yaml
vendored
Normal file
18
.github/workflows/notification.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: Discord Pull Request Notification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: joelwmale/webhook-action@master
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
body: |
|
||||||
|
{
|
||||||
|
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
|
||||||
|
}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ credentials.json
|
|||||||
.continuerc.json
|
.continuerc.json
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.ruby-lsp
|
||||||
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>
|
|
||||||
37
README.md
37
README.md
@@ -8,12 +8,12 @@ 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=159 src="./assets/images/jellyseerr.PNG"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **Skp intro / credits support**
|
- 🚀 **Skip Intro / Credits Support**
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
- 🔊 **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.
|
||||||
@@ -32,22 +32,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
|
||||||
|
|
||||||
@@ -66,11 +61,11 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
|
|||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
|
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
@@ -90,7 +85,7 @@ 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.
|
||||||
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. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on your computer and run the app.
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -108,7 +103,7 @@ Key points of the MPL-2.0:
|
|||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 Connect with Us
|
||||||
|
|
||||||
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
|
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
||||||
|
|
||||||
If you have questions or need support, feel free to reach out:
|
If you have questions or need support, feel free to reach out:
|
||||||
|
|
||||||
@@ -117,7 +112,7 @@ If you have questions or need support, feel free to reach out:
|
|||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
@@ -130,4 +125,4 @@ I'd like to thank the following people and projects for their contributions to S
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#fredrikburmester/streamyfin&Date)
|
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||||
|
|||||||
9
app.json
9
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.23.0",
|
"version": "0.25.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 49,
|
"versionCode": 50,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
@@ -105,13 +105,16 @@
|
|||||||
"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/withTrustLocalCerts.js"]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -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,27 +1,31 @@
|
|||||||
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/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
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";
|
||||||
|
|
||||||
export interface MenuLink {
|
export interface MenuLink {
|
||||||
name: string,
|
name: string;
|
||||||
url: string,
|
url: string;
|
||||||
icon: string
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function menuLinks() {
|
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 {
|
||||||
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
|
const response = await api?.axiosInstance.get(
|
||||||
|
api?.basePath + "/web/config.json"
|
||||||
|
);
|
||||||
const config = response?.data;
|
const config = response?.data;
|
||||||
|
|
||||||
if (!config && !config.hasOwnProperty("menuLinks")) {
|
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||||
@@ -29,15 +33,15 @@ export default function menuLinks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMenuLinks(config?.menuLinks as MenuLink[])
|
setMenuLinks(config?.menuLinks as MenuLink[]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to retrieve config:", error);
|
console.error("Failed to retrieve config:", error);
|
||||||
}
|
}
|
||||||
},
|
}, [api]);
|
||||||
[api]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => { getMenuLinks() }, []);
|
useEffect(() => {
|
||||||
|
getMenuLinks();
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
@@ -47,27 +51,27 @@ export default function menuLinks() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
data={menuLinks}
|
data={menuLinks}
|
||||||
renderItem={({item}) => (
|
renderItem={({ item }) => (
|
||||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
|
<TouchableOpacity onPress={() => 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" />}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
ItemSeparatorComponent={() => (
|
ItemSeparatorComponent={() => (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
}}/>
|
}}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
29
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
29
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function SearchLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: t("tabs.favorites"),
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
app/(auth)/(tabs)/(favorites)/index.tsx
Normal file
36
app/(auth)/(tabs)/(favorites)/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function favorites() {
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="my-4">
|
||||||
|
<Favorites />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
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 { 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";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -13,8 +16,11 @@ 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: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
@@ -34,19 +40,57 @@ 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
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: t("home.settings.settings_title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/optimized-server/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/marlin-search/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/jellyseerr/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/hide-libraries/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/logs/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="intro/page"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
presentation: "modal",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
|||||||
@@ -4,21 +4,29 @@ 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";
|
||||||
import React, {useEffect, useMemo, useRef} from "react";
|
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 {DownloadSize} from "@/components/downloads/DownloadSize";
|
import { useTranslation } from "react-i18next";
|
||||||
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
import { t } from 'i18next';
|
||||||
import {toast} from "sonner-native";
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import {writeToLog} from "@/utils/log";
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
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();
|
||||||
@@ -56,28 +64,29 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||||
onPress={bottomSheetModalRef.current?.present}
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
>
|
|
||||||
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
),
|
||||||
})
|
});
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
const deleteMovies = () => deleteFileByType("Movie")
|
const deleteMovies = () =>
|
||||||
.then(() => toast.success("Deleted all movies successfully!"))
|
deleteFileByType("Movie")
|
||||||
.catch((reason) => {
|
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
|
||||||
writeToLog("ERROR", reason);
|
.catch((reason) => {
|
||||||
toast.error("Failed to delete all movies");
|
writeToLog("ERROR", reason);
|
||||||
});
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
const deleteShows = () => deleteFileByType("Episode")
|
});
|
||||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
const deleteShows = () =>
|
||||||
.catch((reason) => {
|
deleteFileByType("Episode")
|
||||||
writeToLog("ERROR", reason);
|
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
|
||||||
toast.error("Failed to delete all TV-Series");
|
.catch((reason) => {
|
||||||
});
|
writeToLog("ERROR", reason);
|
||||||
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
|
});
|
||||||
|
const deleteAllMedia = async () =>
|
||||||
|
await Promise.all([deleteMovies(), deleteShows()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -90,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 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) => (
|
||||||
@@ -107,7 +116,9 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
<Text className="font-semibold">{q.item.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
<Text className="text-xs opacity-50">
|
||||||
|
{q.item.Type}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -118,25 +129,25 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color="red"/>
|
<Ionicons name="close" size={24} color="red" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ActiveDownloads/>
|
<ActiveDownloads />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{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>
|
||||||
@@ -145,7 +156,7 @@ export default function page() {
|
|||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{movies?.map((item) => (
|
{movies?.map((item) => (
|
||||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||||
<MovieCard item={item.item}/>
|
<MovieCard item={item.item} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -155,15 +166,20 @@ 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">{groupedBySeries?.length}</Text>
|
<Text className="text-xs font-bold">
|
||||||
|
{groupedBySeries?.length}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{groupedBySeries?.map((items) => (
|
{groupedBySeries?.map((items) => (
|
||||||
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
<View
|
||||||
|
className="mb-2 last:mb-0"
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
>
|
||||||
<SeriesCard
|
<SeriesCard
|
||||||
items={items.map((i) => i.item)}
|
items={items.map((i) => i.item)}
|
||||||
key={items[0].item.SeriesId}
|
key={items[0].item.SeriesId}
|
||||||
@@ -176,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>
|
||||||
@@ -200,9 +216,15 @@ 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}>Delete all Movies</Button>
|
<Button color="purple" onPress={deleteMovies}>
|
||||||
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
|
</Button>
|
||||||
|
<Button color="purple" onPress={deleteShows}>
|
||||||
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onPress={deleteAllMedia}>
|
||||||
|
{t("home.downloads.delete_all_button")}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
@@ -214,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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Colors } from "@/constants/Colors";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { HomeSectionStyle, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import {
|
import {
|
||||||
@@ -23,10 +23,11 @@ import {
|
|||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
@@ -55,11 +56,19 @@ type Section = ScrollingCollectionListSection | MediaListSection;
|
|||||||
export default function index() {
|
export default function index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [settings, _] = useSettings();
|
const [
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
setPluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
] = useSettings();
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
@@ -107,16 +116,17 @@ export default function index() {
|
|||||||
setIsConnected(state.isConnected);
|
setIsConnected(state.isConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanCacheDirectory()
|
cleanCacheDirectory().catch((e) =>
|
||||||
.then(r => console.log("Cache directory cleaned"))
|
console.error("Something went wrong cleaning cache directory")
|
||||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: userViews,
|
data,
|
||||||
isError: e1,
|
isError: e1,
|
||||||
isLoading: l1,
|
isLoading: l1,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
@@ -136,28 +146,10 @@ export default function index() {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const userViews = useMemo(
|
||||||
data: mediaListCollections,
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
isError: e2,
|
[data, settings?.hiddenLibraries]
|
||||||
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 collections = useMemo(() => {
|
||||||
const allow = ["movies", "tvshows"];
|
const allow = ["movies", "tvshows"];
|
||||||
@@ -172,6 +164,7 @@ export default function index() {
|
|||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
await invalidateCache();
|
await invalidateCache();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -206,114 +199,160 @@ export default function index() {
|
|||||||
[api, user?.Id]
|
[api, user?.Id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
let sections: Section[] = [];
|
||||||
if (!api || !user?.Id) return [];
|
if (!settings?.home || !settings?.home?.sections) {
|
||||||
|
sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
const title = "Recently Added in " + c.Name;
|
const title = t("home.recently_added_in", {libraryName: c.Name});
|
||||||
const queryKey = [
|
const queryKey = [
|
||||||
"home",
|
"home",
|
||||||
"recentlyAddedIn" + c.CollectionType,
|
"recentlyAddedIn" + c.CollectionType,
|
||||||
user?.Id!,
|
user?.Id!,
|
||||||
c.Id!,
|
c.Id!,
|
||||||
];
|
];
|
||||||
return createCollectionConfig(
|
return createCollectionConfig(
|
||||||
title || "",
|
title || "",
|
||||||
queryKey,
|
queryKey,
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
c.Id
|
c.Id
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
{
|
{
|
||||||
title: "Continue Watching",
|
title: t("home.continue_watching"),
|
||||||
queryKey: ["home", "resumeItems"],
|
queryKey: ["home", "resumeItems"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
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",
|
title: t("home.next_up"),
|
||||||
},
|
queryKey: ["home", "nextUp-all"],
|
||||||
];
|
queryFn: async () =>
|
||||||
return ss;
|
(
|
||||||
}, [api, user?.Id, collections, mediaListCollections]);
|
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: t("home.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: t("home.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]);
|
||||||
|
} else {
|
||||||
|
sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
const ss: Section[] = [];
|
||||||
|
|
||||||
|
for (const key in settings.home?.sections) {
|
||||||
|
const section = settings.home?.sections[key];
|
||||||
|
const id = section.title || key;
|
||||||
|
ss.push({
|
||||||
|
title: id,
|
||||||
|
queryKey: ["home", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (section.items) {
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: section.items?.limit || 25,
|
||||||
|
recursive: true,
|
||||||
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
|
sortBy: section.items?.sortBy,
|
||||||
|
sortOrder: section.items?.sortOrder,
|
||||||
|
filters: section.items?.filters,
|
||||||
|
parentId: section.items?.parentId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
} else if (section.nextUp) {
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: section.items?.limit || 25,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: section.items?.enableResumable || false,
|
||||||
|
enableRewatching: section.items?.enableRewatching || false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: section?.orientation || "vertical",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, settings.home?.sections]);
|
||||||
|
}
|
||||||
|
|
||||||
if (isConnected === false) {
|
if (isConnected === false) {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
<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-3xl font-bold mb-2">{t("home.no_internet")}</Text>
|
||||||
<Text className="text-center opacity-70">
|
<Text className="text-center opacity-70">
|
||||||
No worries, you can still watch{"\n"}downloaded content.
|
{t("home.no_internet_message")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="mt-4">
|
<View className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -324,7 +363,7 @@ export default function index() {
|
|||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Go to downloads
|
{t("home.go_to_downloads")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="black"
|
color="black"
|
||||||
@@ -350,17 +389,15 @@ export default function index() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e1 || e2)
|
if (e1)
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
|
||||||
<Text className="text-center opacity-70">
|
<Text className="text-center opacity-70">{t("home.error_message")}</Text>
|
||||||
Something went wrong.{"\n"}Please log out and in again.
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (l1 || l2)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className="justify-center items-center h-full">
|
||||||
<Loader />
|
<Loader />
|
||||||
|
|||||||
135
app/(auth)/(tabs)/(home)/intro/page.tsx
Normal file
135
app/(auth)/(tabs)/(home)/intro/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import {useTranslation } from "react-i18next";
|
||||||
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
storage.set("hasShownIntro", true);
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
|
||||||
|
<View>
|
||||||
|
<Text className="text-3xl font-bold text-center mb-2">
|
||||||
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-center">
|
||||||
|
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold">{t("home.intro.features_title")}</Text>
|
||||||
|
<Text className="text-xs">
|
||||||
|
{t("home.intro.features_description")}
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-row items-center mt-4">
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className="shrink ml-2">
|
||||||
|
<Text className="font-bold mb-1">Jellyseerr</Text>
|
||||||
|
<Text className="shrink text-xs">
|
||||||
|
{t("home.intro.jellyseerr_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"
|
||||||
|
>
|
||||||
|
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
||||||
|
</View>
|
||||||
|
<View className="shrink ml-2">
|
||||||
|
<Text className="font-bold mb-1">{t("home.intro.downloads_feature_title")}</Text>
|
||||||
|
<Text className="shrink text-xs">
|
||||||
|
{t("home.intro.downloads_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="cast" size={28} color={"white"} />
|
||||||
|
</View>
|
||||||
|
<View className="shrink ml-2">
|
||||||
|
<Text className="font-bold mb-1">Chromecast</Text>
|
||||||
|
<Text className="shrink text-xs">
|
||||||
|
{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>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
{t("home.intro.done_button")}
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
router.push("/settings");
|
||||||
|
}}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,176 +1,109 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import {useDownload} from "@/providers/DownloadProvider";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { DownloadSettings } from "@/components/settings/DownloadSettings";
|
||||||
import { clearLogs, useLog } from "@/utils/log";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
import * as FileSystem from "expo-file-system";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
import * as Haptics from "expo-haptics";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { useAtom } from "jotai";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import * as Progress from "react-native-progress";
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import { clearLogs } from "@/utils/log";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const router = useRouter();
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
|
||||||
const { logs } = useLog();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { logout } = useJellyfin();
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
const successHapticFeedback = useHaptic("success");
|
||||||
queryKey: ["appSize", appSizeUsage],
|
|
||||||
queryFn: async () => {
|
|
||||||
const app = await appSizeUsage;
|
|
||||||
|
|
||||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
|
||||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
|
||||||
|
|
||||||
return { app, remaining, total, used: (total - remaining) / total };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
|
||||||
Alert.prompt(
|
|
||||||
"Quick connect",
|
|
||||||
"Enter the quick connect code",
|
|
||||||
async (text) => {
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
|
||||||
code: text,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
if (res.status === 200) {
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
Alert.alert("Success", "Quick connect authorized");
|
|
||||||
} else {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
toast.error("Error deleting files");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
const onClearLogsClicked = async () => {
|
||||||
clearLogs();
|
clearLogs();
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
successHapticFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-red-600">{t("home.settings.log_out_button")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
{/* <Button
|
<UserInfo />
|
||||||
onPress={() => {
|
<QuickConnect className="mb-4" />
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
registerBackgroundFetchAsync
|
|
||||||
</Button> */}
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<MediaProvider>
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<MediaToggles className="mb-4" />
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<AudioToggles className="mb-4" />
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
<SubtitleToggles className="mb-4" />
|
||||||
</View>
|
</MediaProvider>
|
||||||
<Button className="my-2.5" color="black" onPress={logout}>
|
|
||||||
Log out
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
<OtherSettings />
|
||||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
<DownloadSettings />
|
||||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
|
||||||
Authorize
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<SettingToggles />
|
<PluginSettings />
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<AppLanguageSelector/>
|
||||||
<Text className="font-bold text-lg mb-2">Storage</Text>
|
|
||||||
<View className="mb-4 space-y-2">
|
<ListGroup title={"Intro"}>
|
||||||
{size && <Text>App usage: {size.app.bytesToReadable()}</Text>}
|
<ListItem
|
||||||
<Progress.Bar
|
onPress={() => {
|
||||||
className="bg-gray-100/10"
|
router.push("/intro/page");
|
||||||
indeterminate={appSizeLoading}
|
}}
|
||||||
color="#9333ea"
|
title={t("home.settings.intro.show_intro")}
|
||||||
width={null}
|
/>
|
||||||
height={10}
|
<ListItem
|
||||||
borderRadius={6}
|
textColor="red"
|
||||||
borderWidth={0}
|
onPress={() => {
|
||||||
progress={size?.used}
|
storage.set("hasShownIntro", false);
|
||||||
|
}}
|
||||||
|
title={t("home.settings.intro.reset_intro")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.logs.logs_title")}
|
||||||
/>
|
/>
|
||||||
{size && (
|
<ListItem
|
||||||
<Text>
|
textColor="red"
|
||||||
Available: {size.remaining?.bytesToReadable()}, Total:{" "}
|
onPress={onClearLogsClicked}
|
||||||
{size.total?.bytesToReadable()}
|
title={t("home.settings.logs.delete_all_logs")}
|
||||||
</Text>
|
/>
|
||||||
)}
|
</ListGroup>
|
||||||
</View>
|
|
||||||
<Button color="red" onPress={onDeleteClicked}>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button color="red" onPress={onClearLogsClicked}>
|
|
||||||
Delete all logs
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
{logs?.map((log, index) => (
|
|
||||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
|
||||||
<Text
|
|
||||||
className={`
|
|
||||||
mb-1
|
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{log.level}
|
|
||||||
</Text>
|
|
||||||
<Text uiTextView selectable className="text-xs">
|
|
||||||
{log.message}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{logs?.length === 0 && (
|
|
||||||
<Text className="opacity-50">No logs available</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<StorageSettings />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
67
app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
Normal file
67
app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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 { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { Switch, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading: isLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className="mt-4">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
<ListGroup>
|
||||||
|
{data?.map((view) => (
|
||||||
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
|
<Switch
|
||||||
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({
|
||||||
|
hiddenLibraries: value
|
||||||
|
? [...(settings.hiddenLibraries || []), view.Id!]
|
||||||
|
: settings.hiddenLibraries?.filter((id) => id !== view.Id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
|
</Text>
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
16
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className="p-4"
|
||||||
|
>
|
||||||
|
<JellyseerrSettings />
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
app/(auth)/(tabs)/(home)/settings/logs/page.tsx
Normal file
35
app/(auth)/(tabs)/(home)/settings/logs/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useLog } from "@/utils/log";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { logs } = useLog();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView className="p-4">
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
{logs?.map((log, index) => (
|
||||||
|
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
|
mb-1
|
||||||
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{log.level}
|
||||||
|
</Text>
|
||||||
|
<Text uiTextView selectable className="text-xs">
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{logs?.length === 0 && (
|
||||||
|
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
117
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import React, {useEffect, useMemo, useState} from "react";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const onSave = (val: string) => {
|
||||||
|
updateSettings({
|
||||||
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
|
});
|
||||||
|
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={() => onSave(value)}>
|
||||||
|
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [navigation, value]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
<ListGroup>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||||
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
|
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-row items-center bg-neutral-900 h-11 pr-4`}
|
||||||
|
>
|
||||||
|
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
|
||||||
|
<TextInput
|
||||||
|
editable={settings.searchEngine === "Marlin"}
|
||||||
|
className="text-white"
|
||||||
|
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
|
||||||
|
value={value}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={(text) => setValue(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</DisabledSetting>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
89
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
|
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";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
|
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||||
|
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (newVal: string) => {
|
||||||
|
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||||
|
toast.error(t("home.settings.toasts.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(t("home.settings.toasts.connected"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSave = (newVal: string) => {
|
||||||
|
saveMutation.mutate(newVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: t("home.settings.downloads.optimized_server"),
|
||||||
|
headerRight: () =>
|
||||||
|
saveMutation.isPending ? (
|
||||||
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
||||||
|
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||||
|
className="p-4"
|
||||||
|
>
|
||||||
|
<OptimizedServerForm
|
||||||
|
value={optimizedVersionsServerUrl}
|
||||||
|
onChangeValue={setOptimizedVersionsServerUrl}
|
||||||
|
/>
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 || ""}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
@@ -33,6 +33,7 @@ import * as ScreenOrientation from "expo-screen-orientation";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { 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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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,61 +1,68 @@
|
|||||||
import React, { useCallback, useRef, useState } from "react";
|
|
||||||
import { useLocalSearchParams } 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 { 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 {
|
import { Text } from "@/components/common/Text";
|
||||||
BottomSheetBackdrop,
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
BottomSheetBackdropProps,
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
BottomSheetModal,
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
BottomSheetView,
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
} from "@gorhom/bottom-sheet";
|
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 {
|
import {
|
||||||
IssueType,
|
IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import { Input } from "@/components/common/Input";
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import { useTranslation } from "react-i18next";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetTextInput,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
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 {
|
const { t } = useTranslation();
|
||||||
mediaTitle,
|
|
||||||
releaseYear,
|
|
||||||
canRequest: canRequestString,
|
|
||||||
posterSrc,
|
|
||||||
...result
|
|
||||||
} = params as unknown as {
|
|
||||||
mediaTitle: string;
|
|
||||||
releaseYear: number;
|
|
||||||
canRequest: string;
|
|
||||||
posterSrc: string;
|
|
||||||
} & Partial<MovieResult | TvResult>;
|
|
||||||
|
|
||||||
const canRequest = canRequestString === "true";
|
const { mediaTitle, releaseYear, posterSrc, ...result } =
|
||||||
|
params as unknown as {
|
||||||
|
mediaTitle: string;
|
||||||
|
releaseYear: number;
|
||||||
|
canRequest: string;
|
||||||
|
posterSrc: string;
|
||||||
|
} & Partial<MovieResult | TvResult>;
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
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 {
|
||||||
data: details,
|
data: details,
|
||||||
isFetching,
|
isFetching,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||||
@@ -64,6 +71,7 @@ const Page: React.FC = () => {
|
|||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
retryOnMount: true,
|
retryOnMount: true,
|
||||||
|
refetchInterval: 0,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return result.mediaType === MediaType.MOVIE
|
return result.mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieDetails(result.id!!)
|
? jellyseerrApi?.movieDetails(result.id!!)
|
||||||
@@ -71,6 +79,8 @@ const Page: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -94,18 +104,40 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const request = useCallback(
|
const request = useCallback(async () => {
|
||||||
() =>
|
const body: MediaRequestBody = {
|
||||||
requestMedia(mediaTitle, {
|
mediaId: Number(result.id!!),
|
||||||
mediaId: Number(result.id!!),
|
mediaType: result.mediaType!!,
|
||||||
mediaType: result.mediaType!!,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
seasons: (details as TvDetails)?.seasons
|
||||||
seasons: (details as TvDetails)?.seasons
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
?.map?.((s) => s.seasonNumber),
|
||||||
?.map?.((s) => s.seasonNumber),
|
}
|
||||||
}),
|
|
||||||
[details, result, requestMedia]
|
if (hasAdvancedRequestPermission) {
|
||||||
);
|
advancedReqModalRef?.current?.present?.(body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
if (details) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
|
||||||
|
<ItemActions item={details} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -129,7 +161,10 @@ const Page: React.FC = () => {
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
uri: jellyseerrApi?.imageProxy(
|
||||||
|
result.backdropPath,
|
||||||
|
"w1920_and_h800_multi_faces"
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -178,9 +213,11 @@ const Page: React.FC = () => {
|
|||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
</View>
|
</View>
|
||||||
{canRequest ? (
|
{isLoading || isFetching ? (
|
||||||
|
<Button loading={true} disabled={true} color="purple"></Button>
|
||||||
|
) : canRequest ? (
|
||||||
<Button color="purple" onPress={request}>
|
<Button color="purple" onPress={request}>
|
||||||
Request
|
{t("jellyseerr.request_button")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -195,7 +232,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" />
|
||||||
@@ -206,11 +243,32 @@ const Page: React.FC = () => {
|
|||||||
isLoading={isLoading || isFetching}
|
isLoading={isLoading || isFetching}
|
||||||
result={result as TvResult}
|
result={result as TvResult}
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
|
refetch={refetch}
|
||||||
|
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||||
|
onAdvancedRequest={(data) =>
|
||||||
|
advancedReqModalRef?.current?.present(data)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<DetailFacts
|
||||||
|
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
|
||||||
|
details={details}
|
||||||
|
/>
|
||||||
|
<Cast details={details} />
|
||||||
</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
|
||||||
@@ -226,7 +284,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">
|
||||||
@@ -235,13 +293,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>
|
||||||
@@ -255,7 +313,7 @@ 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) => (
|
||||||
@@ -274,20 +332,23 @@ const Page: React.FC = () => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Input
|
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
||||||
className="w-full"
|
<BottomSheetTextInput
|
||||||
placeholder="(optional) Describe the issue..."
|
multiline
|
||||||
value={issueMessage}
|
maxLength={254}
|
||||||
keyboardType="default"
|
style={{ color: "white" }}
|
||||||
returnKeyType="done"
|
clearButtonMode="always"
|
||||||
autoCapitalize="none"
|
placeholder={t("jellyseerr.describe_the_issue")}
|
||||||
textContentType="none"
|
placeholderTextColor="#9CA3AF"
|
||||||
maxLength={254}
|
// Issue with multiline + Textinput inside a portal
|
||||||
onChangeText={setIssueMessage}
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
/>
|
defaultValue={issueMessage}
|
||||||
|
onChangeText={setIssueMessage}
|
||||||
|
/>
|
||||||
|
</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,110 @@
|
|||||||
|
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 } = 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 locale = useMemo(() => {
|
||||||
|
return jellyseerrUser?.settings?.locale || "en";
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const region = useMemo(
|
||||||
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
|
[jellyseerrUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { Ratings } from "@/components/Ratings";
|
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
@@ -18,9 +16,11 @@ 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 { 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;
|
||||||
@@ -38,7 +38,6 @@ const page: React.FC = () => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: seriesId,
|
itemId: seriesId,
|
||||||
}),
|
}),
|
||||||
enabled: !!seriesId && !!api,
|
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,11 +80,14 @@ const page: React.FC = () => {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
|
item &&
|
||||||
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" />
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
title="Download Series"
|
size="large"
|
||||||
|
title={t("item_card.download.download_series")}
|
||||||
items={allEpisodes || []}
|
items={allEpisodes || []}
|
||||||
MissingDownloadIconComponent={() => (
|
MissingDownloadIconComponent={() => (
|
||||||
<Ionicons name="download" size={22} color="white" />
|
<Ionicons name="download" size={22} color="white" />
|
||||||
@@ -101,7 +103,7 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [allEpisodes, isLoading]);
|
}, [allEpisodes, isLoading, item]);
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
if (!item || !backdropUrl) return null;
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -63,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) {
|
||||||
@@ -299,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())
|
||||||
@@ -326,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)}
|
||||||
/>
|
/>
|
||||||
@@ -351,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())
|
||||||
@@ -369,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 || ""
|
||||||
}
|
}
|
||||||
@@ -389,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 || ""
|
||||||
}
|
}
|
||||||
@@ -435,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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -444,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"
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ 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";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
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,11 +20,15 @@ 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: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -39,11 +46,11 @@ export default function IndexLayout() {
|
|||||||
side={"bottom"}
|
side={"bottom"}
|
||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Display</DropdownMenu.Label>
|
<DropdownMenu.Label>{t("library.options.display")}</DropdownMenu.Label>
|
||||||
<DropdownMenu.Group key="display-group">
|
<DropdownMenu.Group key="display-group">
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
Display
|
{t("library.options.display")}
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubContent
|
||||||
alignOffset={-10}
|
alignOffset={-10}
|
||||||
@@ -66,7 +73,7 @@ export default function IndexLayout() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="display-title-1">
|
<DropdownMenu.ItemTitle key="display-title-1">
|
||||||
Row
|
{t("library.options.row")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
@@ -83,14 +90,14 @@ export default function IndexLayout() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="display-title-2">
|
<DropdownMenu.ItemTitle key="display-title-2">
|
||||||
List
|
{t("library.options.list")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.Sub>
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
Image style
|
{t("library.options.image_style")}
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubContent
|
||||||
alignOffset={-10}
|
alignOffset={-10}
|
||||||
@@ -113,7 +120,7 @@ export default function IndexLayout() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="poster-title">
|
<DropdownMenu.ItemTitle key="poster-title">
|
||||||
Poster
|
{t("library.options.poster")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
@@ -130,7 +137,7 @@ export default function IndexLayout() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="cover-title">
|
<DropdownMenu.ItemTitle key="cover-title">
|
||||||
Cover
|
{t("library.options.cover")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
@@ -154,7 +161,7 @@ export default function IndexLayout() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||||
Show titles
|
{t("library.options.show_titles")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
@@ -171,7 +178,7 @@ export default function IndexLayout() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||||
Show stats
|
{t("library.options.show_stats")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } 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,23 +21,29 @@ 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 () => {
|
||||||
if (!api || !user?.Id) {
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
return null;
|
userId: user?.Id,
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data.Items || null;
|
return response.data.Items || null;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
staleTime: 60,
|
||||||
staleTime: 60 * 1000 * 60,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const libraries = useMemo(
|
||||||
|
() =>
|
||||||
|
data
|
||||||
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
|
.filter((l) => l.CollectionType !== "music")
|
||||||
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
|
[data, settings?.hiddenLibraries]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const item of data || []) {
|
for (const item of data || []) {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
@@ -63,10 +70,10 @@ export default function index() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data)
|
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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -81,7 +88,7 @@ export default function index() {
|
|||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
data={data}
|
data={libraries}
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
ItemSeparatorComponent={() =>
|
ItemSeparatorComponent={() =>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
import {
|
||||||
|
commonScreenOptions,
|
||||||
|
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 +15,10 @@ export default function SearchLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Search",
|
headerTitle: t("tabs.search"),
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -29,10 +37,10 @@ export default function SearchLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||||
name="jellyseerr/page"
|
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
|
||||||
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,7 +22,6 @@ 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,
|
||||||
@@ -30,13 +31,7 @@ import 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 { MovieResult, 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";
|
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -53,6 +48,8 @@ export default function search() {
|
|||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||||
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
@@ -95,6 +92,7 @@ export default function search() {
|
|||||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||||
} else {
|
} else {
|
||||||
if (!settings?.marlinServerUrl) return [];
|
if (!settings?.marlinServerUrl) return [];
|
||||||
|
|
||||||
const url = `${
|
const url = `${
|
||||||
settings.marlinServerUrl
|
settings.marlinServerUrl
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
@@ -102,6 +100,7 @@ export default function search() {
|
|||||||
.join("&includeItemTypes=")}`;
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
const response1 = await axios.get(url);
|
||||||
|
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
if (!ids || !ids.length) return [];
|
if (!ids || !ids.length) return [];
|
||||||
@@ -126,7 +125,7 @@ export default function search() {
|
|||||||
if (Platform.OS === "ios")
|
if (Platform.OS === "ios")
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerSearchBarOptions: {
|
headerSearchBarOptions: {
|
||||||
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);
|
||||||
@@ -147,48 +146,6 @@ export default function search() {
|
|||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
|
||||||
queryKey: ["search", "jellyseerrResults", 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 } = useQuery({
|
|
||||||
queryKey: ["search", "jellyseerrDiscoverSettings", 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 { data: series, isFetching: l2 } = useQuery({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search", "series", debouncedSearch],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -229,64 +186,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 (
|
||||||
<>
|
<>
|
||||||
@@ -298,14 +210,14 @@ export default function search() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-2">
|
<View className="flex flex-col">
|
||||||
{Platform.OS === "android" && (
|
{Platform.OS === "android" && (
|
||||||
<View className="mb-4 px-4">
|
<View className="mb-4 px-4">
|
||||||
<Input
|
<Input
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
keyboardType="web-search"
|
keyboardType="web-search"
|
||||||
placeholder="Search here..."
|
placeholder={t("search.search_here")}
|
||||||
value={search}
|
value={search}
|
||||||
onChangeText={(text) => setSearch(text)}
|
onChangeText={(text) => setSearch(text)}
|
||||||
/>
|
/>
|
||||||
@@ -315,35 +227,33 @@ export default function search() {
|
|||||||
<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-neutral-600" : undefined
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</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-neutral-600" : undefined
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</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
|
||||||
@@ -363,7 +273,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}
|
||||||
@@ -382,7 +292,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}
|
||||||
@@ -396,7 +306,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}
|
||||||
@@ -412,7 +322,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}
|
||||||
@@ -424,168 +334,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} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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 px-4">
|
|
||||||
{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,7 +1,8 @@
|
|||||||
import React from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { withLayoutContext } from "expo-router";
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
@@ -13,12 +14,13 @@ 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";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
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 { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
BottomTabNavigationOptions,
|
||||||
@@ -29,11 +31,29 @@ export const NativeTabs = withLayoutContext<
|
|||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
|
if (!hasShownIntro) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
router.push("/intro/page");
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden={false} style="light" />
|
<SystemBars hidden={false} style="light" />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable
|
sidebarAdaptable={false}
|
||||||
ignoresTopSafeArea
|
ignoresTopSafeArea
|
||||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
@@ -43,46 +63,74 @@ export default function TabLayout() {
|
|||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
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 }) =>
|
||||||
require("@/assets/icons/house.fill.png")
|
require("@/assets/icons/house.fill.png")
|
||||||
: () => ({ sfSymbol: "house" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "house.fill" }
|
||||||
|
: { sfSymbol: "house" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
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 }) =>
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
require("@/assets/icons/magnifyingglass.png")
|
||||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "magnifyingglass" }
|
||||||
|
: { sfSymbol: "magnifyingglass" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name="(favorites)"
|
||||||
|
options={{
|
||||||
|
title: t("tabs.favorites"),
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS == "android"
|
||||||
|
? ({ color, focused, size }) =>
|
||||||
|
focused
|
||||||
|
? require("@/assets/icons/heart.fill.png")
|
||||||
|
: require("@/assets/icons/heart.png")
|
||||||
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "heart.fill" }
|
||||||
|
: { sfSymbol: "heart" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<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 }) =>
|
||||||
require("@/assets/icons/server.rack.png")
|
require("@/assets/icons/server.rack.png")
|
||||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
|
: { sfSymbol: "rectangle.stack" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<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:
|
||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? () => require("@/assets/icons/list.png")
|
? ({ focused }) => require("@/assets/icons/list.png")
|
||||||
: () => ({ sfSymbol: "list.dash" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
: { sfSymbol: "list.dash" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
|||||||
@@ -25,15 +25,6 @@ export default function Layout() {
|
|||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="music-player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
title: "",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -48,11 +48,14 @@ import {
|
|||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import settings from "../(tabs)/(home)/settings";
|
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";
|
||||||
|
|
||||||
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 [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
@@ -68,9 +71,11 @@ export default function page() {
|
|||||||
const { getDownloadedItem } = useDownload();
|
const { getDownloadedItem } = useDownload();
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -104,7 +109,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
if (offline) {
|
||||||
const item = await getDownloadedItem(itemId);
|
const item = await getDownloadedItem(itemId);
|
||||||
if (item) return item.item;
|
if (item) return item.item;
|
||||||
@@ -128,7 +132,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
if (offline) {
|
||||||
const data = await getDownloadedItem(itemId);
|
const data = await getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) return null;
|
if (!data?.mediaSource) return null;
|
||||||
@@ -160,7 +163,7 @@ export default function page() {
|
|||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
if (!sessionId || !mediaSource || !url) {
|
||||||
Alert.alert("Error", "Failed to get stream url");
|
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +180,7 @@ export default function page() {
|
|||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
@@ -195,8 +198,6 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Actually marked as paused");
|
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
if (!offline && stream) {
|
if (!offline && stream) {
|
||||||
@@ -339,7 +340,6 @@ export default function page() {
|
|||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
return async () => {
|
return async () => {
|
||||||
stop();
|
stop();
|
||||||
console.log("Unmounted");
|
|
||||||
};
|
};
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
@@ -349,10 +349,8 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||||
console.log("App has come to the foreground!");
|
|
||||||
// Handle app coming to the foreground
|
// Handle app coming to the foreground
|
||||||
} else if (nextAppState.match(/inactive|background/)) {
|
} else if (nextAppState.match(/inactive|background/)) {
|
||||||
console.log("App has gone to the background!");
|
|
||||||
// Handle app going to the background
|
// Handle app going to the background
|
||||||
if (videoRef.current && videoRef.current.pause) {
|
if (videoRef.current && videoRef.current.pause) {
|
||||||
videoRef.current.pause();
|
videoRef.current.pause();
|
||||||
@@ -418,6 +416,8 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
||||||
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">
|
||||||
@@ -428,7 +428,7 @@ export default function page() {
|
|||||||
if (isErrorItem || isErrorStreamUrl)
|
if (isErrorItem || isErrorStreamUrl)
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -442,7 +442,8 @@ export default function page() {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
||||||
|
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
@@ -466,8 +467,8 @@ export default function page() {
|
|||||||
onVideoError={(e) => {
|
onVideoError={(e) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Error",
|
t("player.error"),
|
||||||
"An error occurred while playing the video. Check logs in settings."
|
t("player.an_error_occured_while_playing_the_video")
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,420 +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 * as Haptics from "expo-haptics";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const windowDimensions = useWindowDimensions();
|
|
||||||
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? parseInt(subtitleIndexStr, 10)
|
|
||||||
: undefined;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!api,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["stream-url"],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const poster = usePoster(item, api);
|
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
|
||||||
async (ticks: number) => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
videoRef,
|
|
||||||
settings,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
console.log("stop");
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.pause();
|
|
||||||
reportPlaybackStopped();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
(seconds: number) => {
|
|
||||||
videoRef.current?.seek(seconds);
|
|
||||||
},
|
|
||||||
[videoRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item.Id,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item?.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = data.currentTime * 10000000;
|
|
||||||
|
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!item?.Id || data.currentTime === 0) return;
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item,
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
isPlaybackStopped,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [play, stop])
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!item || !stream)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: windowDimensions.width,
|
|
||||||
height: windowDimensions.height,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
|
||||||
<Image
|
|
||||||
source={poster}
|
|
||||||
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full opacity-0"
|
|
||||||
>
|
|
||||||
{videoSource && (
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={() => {}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Controls
|
|
||||||
item={item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
enableTrickplay={false}
|
|
||||||
pause={pause}
|
|
||||||
play={play}
|
|
||||||
seek={seek}
|
|
||||||
isVlc={false}
|
|
||||||
stop={stop}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
url?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!item || !api || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: item?.AlbumArtist ?? undefined,
|
|
||||||
title: item?.Name || "Unknown",
|
|
||||||
description: item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [item, api, poster]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -39,15 +39,18 @@ import Video, {
|
|||||||
VideoRef,
|
VideoRef,
|
||||||
} from "react-native-video";
|
} from "react-native-video";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Player = () => {
|
const Player = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const firstTime = useRef(true);
|
const firstTime = useRef(true);
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
@@ -58,7 +61,7 @@ const Player = () => {
|
|||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
@@ -167,7 +170,7 @@ const Player = () => {
|
|||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
@@ -260,13 +263,6 @@ const Player = () => {
|
|||||||
progress.value = ticks;
|
progress.value = ticks;
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
|
||||||
console.log(
|
|
||||||
"onProgress ~",
|
|
||||||
ticks,
|
|
||||||
isPlaying,
|
|
||||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||||
// TODO: since playable duration is always 0 then.
|
// TODO: since playable duration is always 0 then.
|
||||||
setIsBuffering(data.playableDuration === 0);
|
setIsBuffering(data.playableDuration === 0);
|
||||||
@@ -339,11 +335,7 @@ const Player = () => {
|
|||||||
|
|
||||||
// Most likely the subtitle is burned in.
|
// Most likely the subtitle is burned in.
|
||||||
if (embeddedTrackIndex === -1) return;
|
if (embeddedTrackIndex === -1) return;
|
||||||
console.log(
|
|
||||||
"Setting selected text track",
|
|
||||||
subtitleIndex,
|
|
||||||
embeddedTrackIndex
|
|
||||||
);
|
|
||||||
setSelectedTextTrack({
|
setSelectedTextTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: embeddedTrackIndex,
|
value: embeddedTrackIndex,
|
||||||
@@ -384,7 +376,7 @@ const Player = () => {
|
|||||||
if (isErrorItem || isErrorStreamUrl)
|
if (isErrorItem || isErrorStreamUrl)
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -398,7 +390,6 @@ const Player = () => {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
opacity: showControls ? 0.5 : 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{videoSource ? (
|
{videoSource ? (
|
||||||
@@ -425,7 +416,6 @@ const Player = () => {
|
|||||||
playWhenInactive={true}
|
playWhenInactive={true}
|
||||||
allowsExternalPlayback={true}
|
allowsExternalPlayback={true}
|
||||||
playInBackground={true}
|
playInBackground={true}
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
showNotificationControls={true}
|
||||||
ignoreSilentSwitch="ignore"
|
ignoreSilentSwitch="ignore"
|
||||||
fullscreen={false}
|
fullscreen={false}
|
||||||
@@ -439,7 +429,6 @@ const Player = () => {
|
|||||||
setIsBuffering(e.isBuffering);
|
setIsBuffering(e.isBuffering);
|
||||||
}}
|
}}
|
||||||
onAudioTracks={(e) => {
|
onAudioTracks={(e) => {
|
||||||
console.log("onAudioTracks: ", e.audioTracks);
|
|
||||||
setAudioTracks(
|
setAudioTracks(
|
||||||
e.audioTracks.map((t) => ({
|
e.audioTracks.map((t) => ({
|
||||||
index: t.index,
|
index: t.index,
|
||||||
@@ -453,7 +442,7 @@ const Player = () => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text>No video source...</Text>
|
<Text>{t("player.no_video_source")}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -493,7 +482,6 @@ const Player = () => {
|
|||||||
}}
|
}}
|
||||||
getAudioTracks={getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
setAudioTrack={(i) => {
|
setAudioTrack={(i) => {
|
||||||
console.log("setAudioTrack ~", i);
|
|
||||||
setSelectedAudioTrack({
|
setSelectedAudioTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: i,
|
value: i,
|
||||||
@@ -545,7 +533,6 @@ export function useVideoSource(
|
|||||||
startPosition,
|
startPosition,
|
||||||
headers: getAuthHeaders(api),
|
headers: getAuthHeaders(api),
|
||||||
metadata: {
|
metadata: {
|
||||||
artist: item?.AlbumArtist ?? undefined,
|
|
||||||
title: item?.Name || "Unknown",
|
title: item?.Name || "Unknown",
|
||||||
description: item?.Overview ?? undefined,
|
description: item?.Overview ?? undefined,
|
||||||
imageUri: poster,
|
imageUri: poster,
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useGlobalSearchParams } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Alert, Dimensions, View } from "react-native";
|
|
||||||
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useGlobalSearchParams();
|
|
||||||
console.log(searchParams);
|
|
||||||
|
|
||||||
const { url } = searchParams as { url: string };
|
|
||||||
|
|
||||||
const videoId = useMemo(() => {
|
|
||||||
return url.split("v=")[1];
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
|
|
||||||
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
|
||||||
if (state === "ended") {
|
|
||||||
setPlaying(false);
|
|
||||||
Alert.alert("video has finished playing!");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePlaying = useCallback(() => {
|
|
||||||
setPlaying((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
togglePlaying();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const screenWidth = Dimensions.get("screen").width;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col bg-black items-center justify-center h-full">
|
|
||||||
<YoutubePlayer
|
|
||||||
height={300}
|
|
||||||
play={playing}
|
|
||||||
videoId={videoId}
|
|
||||||
onChangeState={onStateChange}
|
|
||||||
width={screenWidth}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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!" }} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
@@ -36,9 +37,12 @@ import * as SplashScreen from "expo-splash-screen";
|
|||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Appearance, AppState } from "react-native";
|
import { Appearance, AppState, TouchableOpacity } 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 { I18nextProvider, useTranslation } from "react-i18next";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
@@ -227,7 +231,9 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<Layout />
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<Layout />
|
||||||
|
</I18nextProvider>
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,6 +257,8 @@ function Layout() {
|
|||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
useNotificationObserver();
|
useNotificationObserver();
|
||||||
|
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAndRequestPermissions();
|
checkAndRequestPermissions();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -264,6 +272,12 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
i18n.changeLanguage(
|
||||||
|
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
|
||||||
|
);
|
||||||
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
const appState = useRef(AppState.currentState);
|
const appState = useRef(AppState.currentState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -335,14 +349,6 @@ function Layout() {
|
|||||||
header: () => null,
|
header: () => null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/trailer/page"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "modal",
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
276
app/login.tsx
276
app/login.tsx
@@ -1,14 +1,16 @@
|
|||||||
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 { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } 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 } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -19,12 +21,11 @@ import {
|
|||||||
} from "react-native";
|
} 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 { setServer, login, removeServer, initiateQuickConnect } =
|
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||||
useJellyfin();
|
useJellyfin();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -38,7 +39,6 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [error, setError] = useState<string>("");
|
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -76,8 +76,10 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
|
className="flex flex-row items-center"
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={24} color="white" />
|
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||||
|
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
@@ -94,9 +96,9 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
setError(error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
setError("An unexpected error occurred");
|
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -120,7 +122,7 @@ const Login: React.FC = () => {
|
|||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
||||||
* - Logs errors and timeout information to the console.
|
* - Logs errors and timeout information to the console.
|
||||||
*/
|
*/
|
||||||
async function checkUrl(url: string) {
|
const checkUrl = useCallback(async (url: string) => {
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -130,15 +132,18 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
|
|
||||||
setServerName(data.ServerName || "");
|
setServerName(data.ServerName || "");
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
* Handles the connection attempt to a Jellyfin server.
|
||||||
@@ -156,159 +161,168 @@ const Login: React.FC = () => {
|
|||||||
* - Sets the server address using `setServer` if the connection is successful.
|
* - Sets the server address using `setServer` if the connection is successful.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const handleConnect = 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServer({ address: url });
|
setServer({ address: url });
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
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"), t("login.enter_code_to_login", {code: code}), [
|
||||||
{
|
{
|
||||||
text: "Got It",
|
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"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (api?.basePath) {
|
return (
|
||||||
return (
|
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<KeyboardAvoidingView
|
||||||
<KeyboardAvoidingView
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
>
|
||||||
style={{ flex: 1, height: "100%" }}
|
{api?.basePath ? (
|
||||||
>
|
<>
|
||||||
<View className="flex flex-col h-full relative items-center justify-center">
|
<View className="flex flex-col h-full relative items-center justify-center">
|
||||||
<View className="px-4 -mt-20 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">{serverURL}</Text>
|
<Text className="text-xs text-neutral-400">
|
||||||
<Input
|
{api.basePath}
|
||||||
placeholder="Username"
|
</Text>
|
||||||
onChangeText={(text) =>
|
<Input
|
||||||
setCredentials({ ...credentials, username: text })
|
placeholder={t("login.username_placeholder")}
|
||||||
}
|
onChangeText={(text) =>
|
||||||
value={credentials.username}
|
setCredentials({ ...credentials, username: text })
|
||||||
autoFocus
|
}
|
||||||
secureTextEntry={false}
|
value={credentials.username}
|
||||||
keyboardType="default"
|
autoFocus
|
||||||
returnKeyType="done"
|
secureTextEntry={false}
|
||||||
autoCapitalize="none"
|
keyboardType="default"
|
||||||
textContentType="username"
|
returnKeyType="done"
|
||||||
clearButtonMode="while-editing"
|
autoCapitalize="none"
|
||||||
maxLength={500}
|
textContentType="username"
|
||||||
/>
|
clearButtonMode="while-editing"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="mb-2"
|
placeholder={t("login.password_placeholder")}
|
||||||
placeholder="Password"
|
onChangeText={(text) =>
|
||||||
onChangeText={(text) =>
|
setCredentials({ ...credentials, password: text })
|
||||||
setCredentials({ ...credentials, password: text })
|
}
|
||||||
}
|
value={credentials.password}
|
||||||
value={credentials.password}
|
secureTextEntry
|
||||||
secureTextEntry
|
keyboardType="default"
|
||||||
keyboardType="default"
|
returnKeyType="done"
|
||||||
returnKeyType="done"
|
autoCapitalize="none"
|
||||||
autoCapitalize="none"
|
textContentType="password"
|
||||||
textContentType="password"
|
clearButtonMode="while-editing"
|
||||||
clearButtonMode="while-editing"
|
maxLength={500}
|
||||||
maxLength={500}
|
/>
|
||||||
/>
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
className="flex-1 mr-2"
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleQuickConnect}
|
||||||
|
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="cellphone-lock"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="text-red-600 mb-2">{error}</Text>
|
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
|
) : (
|
||||||
<Button
|
<>
|
||||||
color="black"
|
<View className="flex flex-col h-full items-center justify-center w-full">
|
||||||
onPress={handleQuickConnect}
|
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||||
className="w-full mb-2"
|
<Image
|
||||||
>
|
style={{
|
||||||
Use Quick Connect
|
width: 100,
|
||||||
</Button>
|
height: 100,
|
||||||
<Button onPress={handleLogin} loading={loading}>
|
marginLeft: -23,
|
||||||
Log in
|
marginBottom: -20,
|
||||||
</Button>
|
}}
|
||||||
|
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||||
|
/>
|
||||||
|
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||||
|
<Text className="text-neutral-500">
|
||||||
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
aria-label="Server URL"
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
value={serverURL}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck}
|
||||||
|
onPress={async () => await handleConnect(serverURL)}
|
||||||
|
className="w-full grow"
|
||||||
|
>
|
||||||
|
{t("server.connect_button")}
|
||||||
|
</Button>
|
||||||
|
<JellyfinServerDiscovery
|
||||||
|
onServerSelect={(server) => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={(s) => {
|
||||||
|
handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</>
|
||||||
</KeyboardAvoidingView>
|
)}
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
||||||
style={{ flex: 1, height: "100%" }}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
|
||||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
|
||||||
<Image
|
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
marginLeft: -23,
|
|
||||||
marginBottom: -20,
|
|
||||||
}}
|
|
||||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
|
||||||
/>
|
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
|
||||||
<Text className="text-neutral-500">
|
|
||||||
Enter the URL to your Jellyfin server
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
placeholder="Server URL"
|
|
||||||
onChangeText={setServerURL}
|
|
||||||
value={serverURL}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<Text className="text-xs text-neutral-500">
|
|
||||||
Make sure to include http or https
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
|
||||||
<Button
|
|
||||||
loading={loadingServerCheck}
|
|
||||||
disabled={loadingServerCheck}
|
|
||||||
onPress={async () => await handleConnect(serverURL)}
|
|
||||||
className="w-full grow"
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
BIN
assets/icons/heart.fill.png
Normal file
BIN
assets/icons/heart.fill.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/heart.png
Normal file
BIN
assets/icons/heart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
118
assets/icons/jellyseerr-logo.svg
Normal file
118
assets/icons/jellyseerr-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/jellyseerr.PNG
Normal file
BIN
assets/images/jellyseerr.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
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,37 +1,35 @@
|
|||||||
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(2)} 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(2)} MB`;
|
|
||||||
|
|
||||||
const kb = bytes / 1024.0;
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
};
|
||||||
|
|
||||||
return `${bytes.toFixed(2)} B`;
|
|
||||||
}
|
|
||||||
|
|
||||||
Number.prototype.secondsToMilliseconds = function () {
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
return this.valueOf() * 1000
|
return this.valueOf() * 1000;
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.minutesToMilliseconds = function () {
|
Number.prototype.minutesToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).secondsToMilliseconds()
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.hoursToMilliseconds = function () {
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).minutesToMilliseconds()
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
}
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
114
components/AddToFavorites.tsx
Normal file
114
components/AddToFavorites.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
||||||
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
type: "item" | "series";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
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 (
|
||||||
|
<View {...props}>
|
||||||
|
<RoundButton
|
||||||
|
size="large"
|
||||||
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
|
fillColor={isFavorite ? "primary" : undefined}
|
||||||
|
onPress={() => {
|
||||||
|
if (isFavorite) {
|
||||||
|
unmarkFavoriteMutation.mutate();
|
||||||
|
} else {
|
||||||
|
markFavoriteMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { useMemo } from "react";
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -26,6 +27,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 +39,7 @@ 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}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { TouchableOpacity, View } from "react-native";
|
|||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { 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,
|
||||||
@@ -59,6 +64,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
@@ -70,7 +77,7 @@ 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,4 +1,4 @@
|
|||||||
import * as Haptics from "expo-haptics";
|
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 { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
@@ -37,12 +37,14 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
case "red":
|
case "red":
|
||||||
return "bg-red-600";
|
return "bg-red-600";
|
||||||
case "black":
|
case "black":
|
||||||
return "bg-neutral-900 border border-neutral-800";
|
return "bg-neutral-900";
|
||||||
case "transparent":
|
case "transparent":
|
||||||
return "bg-transparent";
|
return "bg-transparent";
|
||||||
}
|
}
|
||||||
}, [color]);
|
}, [color]);
|
||||||
|
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className={`
|
className={`
|
||||||
@@ -54,14 +56,16 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!loading && !disabled && onPress) {
|
if (!loading && !disabled && onPress) {
|
||||||
onPress();
|
onPress();
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader />
|
<View className="p-0.5">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!discoveryManager) {
|
if (!discoveryManager) {
|
||||||
|
console.warn("DiscoveryManager is not initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
@@ -77,6 +79,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
[user]
|
[user]
|
||||||
);
|
);
|
||||||
const usingOptimizedServer = useMemo(
|
const usingOptimizedServer = useMemo(
|
||||||
() => settings?.downloadMethod === "optimized",
|
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||||
[settings]
|
[settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ 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,
|
||||||
@@ -212,8 +214,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 +332,7 @@ 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 +370,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>
|
||||||
@@ -391,7 +393,9 @@ export const DownloadSingleItem: React.FC<{
|
|||||||
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={() => (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// GenreTags.tsx
|
// GenreTags.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {View, ViewProps} from "react-native";
|
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
@@ -8,14 +8,15 @@ interface TagProps {
|
|||||||
textClass?: ViewProps["className"]
|
textClass?: ViewProps["className"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
|
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
|
||||||
text,
|
text,
|
||||||
textClass,
|
textClass,
|
||||||
|
textStyle,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||||
<Text className={textClass}>{text}</Text>
|
<Text className={textClass} style={textStyle}>{text}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -26,7 +27,7 @@ export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-x
|
|||||||
return (
|
return (
|
||||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||||
{tags.map((tag, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<View>
|
<View key={idx}>
|
||||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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";
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -90,6 +91,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<DownloadSingleItem item={item} size="large" />
|
<DownloadSingleItem item={item} size="large" />
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
|
<AddToFavorites item={item} type="item" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -22,15 +23,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 +54,14 @@ 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 +72,7 @@ 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(
|
||||||
@@ -175,6 +177,8 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
) as MediaStream;
|
) as MediaStream;
|
||||||
}, [source.MediaStreams]);
|
}, [source.MediaStreams]);
|
||||||
|
|
||||||
|
if (!videoStream) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className="flex-row flex-wrap gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
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,35 +0,0 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
title?: string | null | undefined;
|
|
||||||
subTitle?: string | null | undefined;
|
|
||||||
children?: ReactNode;
|
|
||||||
iconAfter?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|
||||||
title,
|
|
||||||
subTitle,
|
|
||||||
iconAfter,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col overflow-visible">
|
|
||||||
<Text className="font-bold ">{title}</Text>
|
|
||||||
{subTitle && (
|
|
||||||
<Text uiTextView selectable className="text-xs text-neutral-400">
|
|
||||||
{subTitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{iconAfter}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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 { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { 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;
|
||||||
@@ -29,6 +28,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 +61,7 @@ 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 +85,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 +94,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
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import Animated, {
|
|||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -50,6 +51,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);
|
||||||
@@ -64,6 +66,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
@@ -79,7 +82,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
@@ -131,8 +134,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
if (!data?.url) {
|
if (!data?.url) {
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Client error",
|
t("player.client_error"),
|
||||||
"Could not create stream for Chromecast"
|
t("player.could_not_create_stream_for_chromecast")
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
51
components/PreviousServersList.tsx
Normal file
51
components/PreviousServersList.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useMMKVString } from "react-native-mmkv";
|
||||||
|
import { ListGroup } from "./list/ListGroup";
|
||||||
|
import { ListItem } from "./list/ListItem";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviousServersListProps {
|
||||||
|
onServerSelect: (server: Server) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||||
|
onServerSelect,
|
||||||
|
}) => {
|
||||||
|
const [_previousServers, setPreviousServers] =
|
||||||
|
useMMKVString("previousServers");
|
||||||
|
|
||||||
|
const previousServers = useMemo(() => {
|
||||||
|
return JSON.parse(_previousServers || "[]") as Server[];
|
||||||
|
}, [_previousServers]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("server.previous_servers")} className="mt-4">
|
||||||
|
{previousServers.map((s) => (
|
||||||
|
<ListItem
|
||||||
|
key={s.address}
|
||||||
|
onPress={() => onServerSelect(s)}
|
||||||
|
title={s.address}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
setPreviousServers("[]");
|
||||||
|
}}
|
||||||
|
title={t("server.clear_button")}
|
||||||
|
textColor="red"
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,10 +6,10 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
onPress?: () => void,
|
onPress?: () => void;
|
||||||
icon?: keyof typeof Ionicons.glyphMap;
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
background?: boolean;
|
background?: boolean;
|
||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
@@ -29,10 +29,11 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
||||||
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
if (hapticFeedback) {
|
if (hapticFeedback) {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
}
|
}
|
||||||
onPress?.();
|
onPress?.();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Platform, TouchableOpacity, View } from "react-native";
|
|||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
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;
|
||||||
@@ -37,6 +38,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams.length === 0) return null;
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex col shrink justify-start place-self-start items-start"
|
className="flex col shrink justify-start place-self-start items-start"
|
||||||
@@ -48,12 +51,12 @@ 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 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>
|
||||||
|
|||||||
108
components/common/Dropdown.tsx
Normal file
108
components/common/Dropdown.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import {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>) => {
|
||||||
|
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}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function Input(props: TextInputProps) {
|
|||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
className="p-4 rounded-xl bg-neutral-900"
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
placeholderTextColor={"#9CA3AF"}
|
placeholderTextColor={"#9CA3AF"}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
import * as Haptics from "expo-haptics";
|
BaseItemDto,
|
||||||
|
BaseItemPerson,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren } 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 * as ContextMenu from "zeego/context-menu";
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
export const itemRouter = (
|
||||||
if (item.CollectionType === "livetv") {
|
item: BaseItemDto | BaseItemPerson,
|
||||||
|
from: string
|
||||||
|
) => {
|
||||||
|
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||||
return `/(auth)/(tabs)/${from}/livetv`;
|
return `/(auth)/(tabs)/${from}/livetv`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,19 +26,7 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
|||||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "MusicAlbum") {
|
if (item.Type === "Person" || item.Type === "Actor") {
|
||||||
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") {
|
|
||||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,85 +56,51 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const showActionSheet = useCallback(() => {
|
||||||
|
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
|
||||||
|
const cancelButtonIndex = 2;
|
||||||
|
|
||||||
|
showActionSheetWithOptions(
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
cancelButtonIndex,
|
||||||
|
},
|
||||||
|
async (selectedIndex) => {
|
||||||
|
if (selectedIndex === 0) {
|
||||||
|
await markAsPlayedStatus(true);
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
} else if (selectedIndex === 1) {
|
||||||
|
await markAsPlayedStatus(false);
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [showActionSheetWithOptions, markAsPlayedStatus]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
from === "(home)" ||
|
||||||
|
from === "(search)" ||
|
||||||
|
from === "(libraries)" ||
|
||||||
|
from === "(favorites)"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onLongPress={showActionSheet}
|
||||||
<TouchableOpacity
|
onPress={() => {
|
||||||
onPress={() => {
|
const url = itemRouter(item, from);
|
||||||
const url = itemRouter(item, from);
|
// @ts-expect-error
|
||||||
// @ts-ignore
|
router.push(url);
|
||||||
router.push(url);
|
}}
|
||||||
}}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
avoidCollisions
|
|
||||||
alignOffset={0}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={false}
|
|
||||||
key={"content"}
|
|
||||||
>
|
|
||||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
|
||||||
<ContextMenu.Item
|
|
||||||
key="item-1"
|
|
||||||
onSelect={() => {
|
|
||||||
markAsPlayedStatus(true);
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key="item-1-title">
|
|
||||||
Mark as watched
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
|
||||||
pointSize: 18,
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "green", // Changed to green for "watched"
|
|
||||||
light: "green",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
androidIconName="checkmark-circle"
|
|
||||||
></ContextMenu.ItemIcon>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Item
|
|
||||||
key="item-2"
|
|
||||||
onSelect={() => {
|
|
||||||
markAsPlayedStatus(false);
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
destructive
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key="item-2-title">
|
|
||||||
Mark as not watched
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
|
||||||
pointSize: 18, // Adjusted for better visibility
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "red", // Changed to red for "not watched"
|
|
||||||
light: "red",
|
|
||||||
},
|
|
||||||
// Removed paletteColors as it's not necessary in this case
|
|
||||||
}}
|
|
||||||
androidIconName="eye-slash"
|
|
||||||
></ContextMenu.ItemIcon>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 { 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";
|
||||||
@@ -9,7 +8,6 @@ import { checkForExistingDownloads } from "@kesha-antonov/react-native-backgroun
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -22,6 +20,7 @@ import { Button } from "../Button";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { t } from "i18next";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -30,14 +29,14 @@ 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">{t("home.downloads.active_download")}</Text>
|
||||||
<Text className="opacity-50">No active downloads</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) => (
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
<DownloadCard key={p.item.Id} process={p} />
|
||||||
@@ -62,7 +61,7 @@ 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 checkForExistingDownloads();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
@@ -82,11 +81,11 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
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 +152,7 @@ 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,5 +1,5 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +26,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(item.Id!);
|
return storage.getString(item.Id!);
|
||||||
@@ -41,7 +42,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id);
|
deleteFile(item.Id);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
successHapticFeedback();
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
openFile(item);
|
openFile(item);
|
||||||
@@ -43,7 +44,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id);
|
deleteFile(item.Id);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
successHapticFeedback();
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
100
components/home/Favorites.tsx
Normal file
100
components/home/Favorites.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ScrollingCollectionList } from "./ScrollingCollectionList";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { t } from "i18next";
|
||||||
|
|
||||||
|
export const Favorites = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const fetchFavoritesByType = useCallback(
|
||||||
|
async (itemType: BaseItemKind) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id!,
|
||||||
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
filters: ["IsFavorite"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
excludeLocationTypes: ["Virtual"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
limit: 20,
|
||||||
|
includeItemTypes: [itemType],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
[api, user]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchFavoriteSeries = useCallback(
|
||||||
|
() => fetchFavoritesByType("Series"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteMovies = useCallback(
|
||||||
|
() => fetchFavoritesByType("Movie"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteEpisodes = useCallback(
|
||||||
|
() => fetchFavoritesByType("Episode"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteVideos = useCallback(
|
||||||
|
() => fetchFavoritesByType("Video"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteBoxsets = useCallback(
|
||||||
|
() => fetchFavoritesByType("BoxSet"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoritePlaylists = useCallback(
|
||||||
|
() => fetchFavoritesByType("Playlist"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-co gap-y-4">
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteSeries}
|
||||||
|
queryKey={["home", "favorites", "series"]}
|
||||||
|
title={t("favorites.series")}
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteMovies}
|
||||||
|
queryKey={["home", "favorites", "movies"]}
|
||||||
|
title={t("favorites.movies")}
|
||||||
|
hideIfEmpty
|
||||||
|
orientation="vertical"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteEpisodes}
|
||||||
|
queryKey={["home", "favorites", "episodes"]}
|
||||||
|
title={t("favorites.episodes")}
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteVideos}
|
||||||
|
queryKey={["home", "favorites", "videos"]}
|
||||||
|
title={t("favorites.videos")}
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteBoxsets}
|
||||||
|
queryKey={["home", "favorites", "boxsets"]}
|
||||||
|
title={t("favorites.boxsets")}
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoritePlaylists}
|
||||||
|
queryKey={["home", "favorites", "playlists"]}
|
||||||
|
title={t("favorites.playlists")}
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
</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 * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -84,21 +83,27 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
const width = Dimensions.get("screen").width;
|
const width = Dimensions.get("screen").width;
|
||||||
|
|
||||||
|
if (settings?.usePopularPlugin === false) return null;
|
||||||
if (l1 || l2) return null;
|
if (l1 || l2) return null;
|
||||||
if (!popularItems) return null;
|
if (!popularItems) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center" {...props}>
|
<View className="flex flex-col items-center mt-2" {...props}>
|
||||||
<Carousel
|
<Carousel
|
||||||
autoPlay={true}
|
|
||||||
autoPlayInterval={3000}
|
|
||||||
loop={true}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
autoPlay={false}
|
||||||
|
loop={true}
|
||||||
|
snapEnabled={true}
|
||||||
|
mode="parallax"
|
||||||
|
modeConfig={{
|
||||||
|
parallaxScrollingScale: 0.86,
|
||||||
|
parallaxScrollingOffset: 100,
|
||||||
|
}}
|
||||||
width={width}
|
width={width}
|
||||||
height={204}
|
height={204}
|
||||||
data={popularItems}
|
data={popularItems}
|
||||||
onProgressChange={progress}
|
onProgressChange={progress}
|
||||||
renderItem={({ item, index }) => <RenderItem item={item} />}
|
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
|
||||||
/>
|
/>
|
||||||
<Pagination.Basic
|
<Pagination.Basic
|
||||||
progress={progress}
|
progress={progress}
|
||||||
@@ -122,6 +127,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const screenWidth = Dimensions.get("screen").width;
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const uri = useMemo(() => {
|
const uri = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -147,7 +153,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const handleRoute = useCallback(() => {
|
const handleRoute = useCallback(() => {
|
||||||
if (!from) return;
|
if (!from) return;
|
||||||
const url = itemRouter(item, from);
|
const url = itemRouter(item, from);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (url) router.push(url);
|
if (url) router.push(url);
|
||||||
}, [item, from]);
|
}, [item, from]);
|
||||||
@@ -155,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;
|
||||||
@@ -18,6 +19,7 @@ interface Props extends ViewProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
|
hideIfEmpty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -26,10 +28,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// console.log(queryKey);
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
@@ -39,16 +40,19 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (hideIfEmpty === true && data?.length === 0) return null;
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="">
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</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 ? (
|
||||||
@@ -82,15 +86,13 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
) : (
|
) : (
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{data?.map((item, index) => (
|
{data?.map((item) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={index}
|
key={item.Id}
|
||||||
className={`
|
className={`mr-2
|
||||||
mr-2
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
|
`}
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
@@ -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