forked from Ninjalama/streamyfin_mirror
Compare commits
224 Commits
feat/techn
...
fix/maven-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2be8096f4a | ||
|
|
38aad9610b | ||
|
|
54af64abef | ||
|
|
e1720a00da | ||
|
|
882d0ea188 | ||
|
|
f3b539232f | ||
|
|
33ea657a5c | ||
|
|
75820adcbc | ||
|
|
76cdb2b3f8 | ||
|
|
0a2ea33635 | ||
|
|
aad6093852 | ||
|
|
c553cff9d1 | ||
|
|
dcd458bd3d | ||
|
|
05dc61d17d | ||
|
|
e4de11127f | ||
|
|
2dc49735f4 | ||
|
|
ef42207174 | ||
|
|
efa5638b12 | ||
|
|
c63cea891d | ||
|
|
4e80f58823 | ||
|
|
cfe39d504c | ||
|
|
cf43d1a657 | ||
|
|
cbe3b18226 | ||
|
|
b637a0f7d2 | ||
|
|
a0ce7cc6d0 | ||
|
|
a640df30bc | ||
|
|
062e6e6c23 | ||
|
|
d709e3b13e | ||
|
|
b232bebd73 | ||
|
|
90ef8ef6f9 | ||
|
|
0df6b8e2a0 | ||
|
|
f48b26076d | ||
|
|
c86a8438e5 | ||
|
|
faa2baae68 | ||
|
|
ed42371353 | ||
|
|
24277135a8 | ||
|
|
23d9cd36d1 | ||
|
|
b243524a7d | ||
|
|
8288682e68 | ||
|
|
58ec915699 | ||
|
|
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 | ||
|
|
5dc86d4765 | ||
|
|
d13731c28f | ||
|
|
7f0446b85f | ||
|
|
11fbe19f80 | ||
|
|
5c97b85492 | ||
|
|
e60cec69f8 | ||
|
|
7bc1c22770 | ||
|
|
e86dab5613 | ||
|
|
eeb803223c | ||
|
|
1a43f7ef1b | ||
|
|
f4624bdc25 | ||
|
|
3c5f2b4079 | ||
|
|
955190a9cc | ||
|
|
e1e4f4833c | ||
|
|
3b987646a6 | ||
|
|
0e574ea18d | ||
|
|
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 | ||
|
|
91527b83dd | ||
|
|
14138151a3 | ||
|
|
6c2bfe2a45 | ||
|
|
996cd36a9e | ||
|
|
6aa2e00d93 | ||
|
|
344e0932dc | ||
|
|
eaffffb2f0 | ||
|
|
f6c0513d2d | ||
|
|
013f064280 | ||
|
|
cd2c3f359e | ||
|
|
123c6bba05 | ||
|
|
a1ea926342 | ||
|
|
6a17ac02af | ||
|
|
815be2a175 | ||
|
|
ece3bc001f | ||
|
|
27609e7789 | ||
|
|
347f196a6a | ||
|
|
468f58e531 | ||
|
|
a994868be4 | ||
|
|
ee6d43e3e8 | ||
|
|
f8d22bb7d6 | ||
|
|
a0391b484d | ||
|
|
681aadb121 | ||
|
|
479a1f037e | ||
|
|
ae5b88ab56 | ||
|
|
9091b9b66a | ||
|
|
cccb26c9cc | ||
|
|
28568cbb9c | ||
|
|
8344d4025b | ||
|
|
0f69448081 | ||
|
|
a936916da4 | ||
|
|
c753e33f38 | ||
|
|
48422fa93e | ||
|
|
5adf943fd9 | ||
|
|
9174a8104d | ||
|
|
56f1bd489c | ||
|
|
5e79b5a581 | ||
|
|
36a689f59d | ||
|
|
47211ba009 | ||
|
|
e86a2af9a9 | ||
|
|
c46b4cc34d | ||
|
|
ec0d9d7788 | ||
|
|
d2eda1365c | ||
|
|
b58fa86a6b | ||
|
|
400dfe3679 | ||
|
|
cf58a5e749 | ||
|
|
001eba02b4 | ||
|
|
67e767f298 | ||
|
|
5f1c5f7b34 | ||
|
|
e54cac1e09 | ||
|
|
cbce83e109 | ||
|
|
c6b58c5c28 | ||
|
|
0468756317 | ||
|
|
9f12ee027f | ||
|
|
78b7425c6b | ||
|
|
c38c1d06ad | ||
|
|
5af735065a | ||
|
|
600276cb69 | ||
|
|
ba3104f87e | ||
|
|
3aef9458e3 | ||
|
|
5bce394836 | ||
|
|
90930d478c | ||
|
|
dd09f3d4d9 | ||
|
|
8608ad02f7 | ||
|
|
030947fc38 | ||
|
|
9b18188b32 | ||
|
|
d86853dec9 | ||
|
|
0750acdc13 | ||
|
|
d8231f5b80 | ||
|
|
41d17499bb | ||
|
|
60f1217cae | ||
|
|
834de10e34 | ||
|
|
51f17f983d | ||
|
|
ba4a2c0b79 | ||
|
|
a32eb710ec | ||
|
|
cb05da782a | ||
|
|
5a680a4392 | ||
|
|
8a44d2ff15 | ||
|
|
f3f260625f | ||
|
|
6908620f4e | ||
|
|
9932266203 | ||
|
|
cb2268e39c | ||
|
|
bf9be278d3 | ||
|
|
584fcc09d6 | ||
|
|
7a26b5004b | ||
|
|
ae92692ea0 | ||
|
|
92e4b3b8cf | ||
|
|
127ec1391b | ||
|
|
0ac4f826bc |
18
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
18
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,13 +1,11 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: '[Bug]: '
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- ['❌ bug']
|
||||
projects:
|
||||
- ['fredrikburmester/5']
|
||||
assignees:
|
||||
- fredrikburmester
|
||||
|
||||
- ["❌ bug"]
|
||||
projects:
|
||||
- ["streamyfin/3"]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
@@ -43,8 +41,9 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Streamyfin are you running?
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.25.0
|
||||
- 0.22.0
|
||||
- 0.21.0
|
||||
- older
|
||||
@@ -54,6 +53,5 @@ body:
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label:
|
||||
If applicable, please add screenshots to help explain your problem.
|
||||
label: If applicable, please add screenshots to help explain your problem.
|
||||
You can drag and drop images here or paste them directly into the comment box.
|
||||
|
||||
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: ''
|
||||
labels: '✨ enhancement'
|
||||
assignees: ''
|
||||
|
||||
projects:
|
||||
- streamyfin/3
|
||||
---
|
||||
|
||||
**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"
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -35,4 +35,6 @@ credentials.json
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
|
||||
.vscode/
|
||||
.vscode/
|
||||
.idea/
|
||||
.ruby-lsp
|
||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[submodule "utils/jellyseerr"]
|
||||
path = utils/jellyseerr
|
||||
url = https://github.com/herrrta/jellyseerr
|
||||
branch = models
|
||||
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>
|
||||
55
README.md
55
README.md
@@ -8,17 +8,17 @@ 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/screenshot3.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||
|
||||
<img width=150 src="./assets/images/jellyseerr.PNG"/>
|
||||
</div>
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- 🚀 **Skp intro / credits support**
|
||||
- 🚀 **Skip Intro / Credits Support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
@@ -66,15 +66,13 @@ 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>
|
||||
</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
|
||||
|
||||
Get the latest updates by using the TestFlight version of the app.
|
||||
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.
|
||||
|
||||
<a href="https://testflight.apple.com/join/CWBaAAK2">
|
||||
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
||||
</a>
|
||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -89,36 +87,10 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
||||
|
||||
### Development info
|
||||
|
||||
1. Use node `20`
|
||||
2. Install dependencies `bun i`
|
||||
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
||||
|
||||
## Extended chromecast controls
|
||||
|
||||
Add this to AppDelegate.mm:
|
||||
|
||||
```
|
||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
||||
{
|
||||
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091
|
||||
#if __has_include(<GoogleCast/GoogleCast.h>)
|
||||
...
|
||||
|
||||
[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;`
|
||||
#endif
|
||||
```
|
||||
|
||||
Add this to Info.plist:
|
||||
|
||||
```
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
|
||||
```
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
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.
|
||||
|
||||
## 📄 License
|
||||
|
||||
@@ -136,7 +108,7 @@ Key points of the MPL-2.0:
|
||||
|
||||
## 🌐 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:
|
||||
|
||||
@@ -145,7 +117,7 @@ If you have questions or need support, feel free to reach out:
|
||||
|
||||
## 📝 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
|
||||
|
||||
@@ -153,8 +125,9 @@ I'd like to thank the following people and projects for their contributions to S
|
||||
|
||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||
- The Jellyfin devs for always being helpful in the Discord.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#fredrikburmester/streamyfin&Date)
|
||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||
|
||||
25
app.json
25
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.22.0",
|
||||
"version": "0.25.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 47,
|
||||
"versionCode": 50,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
},
|
||||
@@ -78,18 +78,16 @@
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"android": {
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 34,
|
||||
"buildToolsVersion": "34.0.0"
|
||||
},
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 34,
|
||||
"buildToolsVersion": "34.0.0",
|
||||
"minSdkVersion": 24,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
"jniLibs": {
|
||||
"useLegacyPackaging": true
|
||||
}
|
||||
}
|
||||
"extraMavenRepos": [
|
||||
"https://repo.maven.apache.org/maven2",
|
||||
"https://uk.maven.org/maven2"
|
||||
],
|
||||
"useLegacyPackaging": true
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -111,7 +109,8 @@
|
||||
{ "android": { "parentTheme": "Material3" } }
|
||||
],
|
||||
["react-native-bottom-tabs"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"]
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withGoogleCastActivity.js"]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import {FlatList, TouchableOpacity, View} from "react-native";
|
||||
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
||||
import React, {useCallback, useEffect, useState} from "react";
|
||||
import {useAtom} from "jotai/index";
|
||||
import {apiAtom} from "@/providers/JellyfinProvider";
|
||||
import {ListItem} from "@/components/ListItem";
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import {Text} from "@/components/common/Text";
|
||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
export interface MenuLink {
|
||||
name: string,
|
||||
url: string,
|
||||
icon: string
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export default function menuLinks() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets()
|
||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
|
||||
const insets = useSafeAreaInsets();
|
||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||
|
||||
const getMenuLinks = useCallback(async () => {
|
||||
try {
|
||||
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
|
||||
const response = await api?.axiosInstance.get(
|
||||
api?.basePath + "/web/config.json"
|
||||
);
|
||||
const config = response?.data;
|
||||
|
||||
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||
@@ -29,15 +31,15 @@ export default function menuLinks() {
|
||||
return;
|
||||
}
|
||||
|
||||
setMenuLinks(config?.menuLinks as MenuLink[])
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve config:", error);
|
||||
}
|
||||
},
|
||||
[api]
|
||||
)
|
||||
setMenuLinks(config?.menuLinks as MenuLink[]);
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve config:", error);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => { getMenuLinks() }, []);
|
||||
useEffect(() => {
|
||||
getMenuLinks();
|
||||
}, []);
|
||||
return (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
@@ -47,27 +49,27 @@ export default function menuLinks() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
data={menuLinks}
|
||||
renderItem={({item}) => (
|
||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}>
|
||||
<ListItem
|
||||
title={item.name}
|
||||
iconAfter={<Ionicons name="link" size={24} color="white"/>}
|
||||
title={item.name}
|
||||
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
)}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}/>
|
||||
)}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
||||
</View>
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
27
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
27
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function SearchLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "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,4 +1,5 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
@@ -15,6 +16,9 @@ export default function IndexLayout() {
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Home",
|
||||
headerBlurEffect: "prominent",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
@@ -49,6 +53,44 @@ export default function IndexLayout() {
|
||||
title: "Settings",
|
||||
}}
|
||||
/>
|
||||
<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/popular-lists/page"
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/hide-libraries/page"
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="intro/page"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
|
||||
@@ -6,16 +6,21 @@ import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {useNavigation, useRouter} from "expo-router";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {useEffect, useMemo, useRef} from "react";
|
||||
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
||||
import {toast} from "sonner-native";
|
||||
import {writeToLog} from "@/utils/log";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { toast } from "sonner-native";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
@@ -56,28 +61,29 @@ export default function page() {
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={bottomSheetModalRef.current?.present}
|
||||
>
|
||||
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})
|
||||
),
|
||||
});
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const deleteMovies = () => deleteFileByType("Movie")
|
||||
.then(() => toast.success("Deleted all movies successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all movies");
|
||||
});
|
||||
const deleteShows = () => deleteFileByType("Episode")
|
||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all TV-Series");
|
||||
});
|
||||
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
||||
const deleteMovies = () =>
|
||||
deleteFileByType("Movie")
|
||||
.then(() => toast.success("Deleted all movies successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all movies");
|
||||
});
|
||||
const deleteShows = () =>
|
||||
deleteFileByType("Episode")
|
||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all TV-Series");
|
||||
});
|
||||
const deleteAllMedia = async () =>
|
||||
await Promise.all([deleteMovies(), deleteShows()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -94,7 +100,7 @@ export default function page() {
|
||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">Queue</Text>
|
||||
<Text className="text-xs opacity-70 text-red-600">
|
||||
Queue and downloads will be lost on app restart
|
||||
Queue and active downloads will be lost on app restart
|
||||
</Text>
|
||||
<View className="flex flex-col space-y-2 mt-2">
|
||||
{queue.map((q, index) => (
|
||||
@@ -107,7 +113,9 @@ export default function page() {
|
||||
>
|
||||
<View>
|
||||
<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>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
@@ -118,7 +126,7 @@ export default function page() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red"/>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -130,7 +138,7 @@ export default function page() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ActiveDownloads/>
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
@@ -145,7 +153,7 @@ export default function page() {
|
||||
<View className="px-4 flex flex-row">
|
||||
{movies?.map((item) => (
|
||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||
<MovieCard item={item.item}/>
|
||||
<MovieCard item={item.item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -157,13 +165,18 @@ export default function page() {
|
||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||
<Text className="text-lg font-bold">TV-Series</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
|
||||
<Text className="text-xs font-bold">
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
||||
<View
|
||||
className="mb-2 last:mb-0"
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
@@ -200,9 +213,15 @@ export default function page() {
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="p-4 space-y-4 mb-4">
|
||||
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
|
||||
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
|
||||
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
|
||||
<Button color="purple" onPress={deleteMovies}>
|
||||
Delete all Movies
|
||||
</Button>
|
||||
<Button color="purple" onPress={deleteShows}>
|
||||
Delete all TV-Series
|
||||
</Button>
|
||||
<Button color="red" onPress={deleteAllMedia}>
|
||||
Delete all
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
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 { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -107,16 +107,16 @@ export default function index() {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
cleanCacheDirectory()
|
||||
.then(r => console.log("Cache directory cleaned"))
|
||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
||||
cleanCacheDirectory().catch((e) =>
|
||||
console.error("Something went wrong cleaning cache directory")
|
||||
);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: userViews,
|
||||
data,
|
||||
isError: e1,
|
||||
isLoading: l1,
|
||||
} = useQuery({
|
||||
@@ -136,6 +136,11 @@ export default function index() {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const userViews = useMemo(
|
||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||
[data, settings?.hiddenLibraries]
|
||||
);
|
||||
|
||||
const {
|
||||
data: mediaListCollections,
|
||||
isError: e2,
|
||||
|
||||
109
app/(auth)/(tabs)/(home)/intro/page.tsx
Normal file
109
app/(auth)/(tabs)/(home)/intro/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
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 { TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const router = useRouter();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
storage.set("hasShownIntro", true);
|
||||
}, [])
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="bg-neutral-900 h-full py-32 px-4 space-y-4">
|
||||
<View>
|
||||
<Text className="text-3xl font-bold text-center mb-2">
|
||||
Welcome to Streamyfin
|
||||
</Text>
|
||||
<Text className="text-center">
|
||||
A free and open source client for Jellyfin.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-lg font-bold">Features</Text>
|
||||
<Text className="text-xs">
|
||||
Streamyfin has a bunch of features and integrates with a wide array of
|
||||
software which you can find in the settings menu, these include:
|
||||
</Text>
|
||||
<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">
|
||||
Connect to your Jellyseerr instance and request movies directly in
|
||||
the app.
|
||||
</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">Downloads</Text>
|
||||
<Text className="shrink text-xs">
|
||||
Download movies and tv-shows to view offline. Use either the
|
||||
default method or install the optimize server to download files in
|
||||
the background.
|
||||
</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">
|
||||
Cast movies and tv-shows to your Chromecast devices.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
router.back();
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.back();
|
||||
router.push("/settings");
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
<Text className="text-purple-600 text-center">Go to settings</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +1,105 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/ListItem";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {clearLogs, useLog} from "@/utils/log";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import {Alert, ScrollView, View} from "react-native";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import { DownloadSettings } from "@/components/settings/DownloadSettings";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
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 { useEffect } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import * as Progress from 'react-native-progress';
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { logs } = useLog();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const {data: size , isLoading: appSizeLoading } = useQuery({
|
||||
queryKey: ["appSize", appSizeUsage],
|
||||
queryFn: async () => {
|
||||
const app = await appSizeUsage
|
||||
|
||||
const remaining = await FileSystem.getFreeDiskStorageAsync()
|
||||
const total = await FileSystem.getTotalDiskCapacityAsync()
|
||||
|
||||
return {app, remaining, total, used: (total - remaining) / total}
|
||||
}
|
||||
})
|
||||
|
||||
const openQuickConnectAuthCodeInput = () => {
|
||||
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 { logout } = useJellyfin();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const onClearLogsClicked = async () => {
|
||||
clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
successHapticFeedback();
|
||||
};
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<Text className="text-red-600">Log out</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className="p-4 flex flex-col gap-y-4">
|
||||
{/* <Button
|
||||
onPress={() => {
|
||||
registerBackgroundFetchAsync();
|
||||
}}
|
||||
>
|
||||
registerBackgroundFetchAsync
|
||||
</Button> */}
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
||||
<UserInfo />
|
||||
<QuickConnect className="mb-4" />
|
||||
|
||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||
<ListItem title="User" subTitle={user?.Name} />
|
||||
<ListItem title="Server" subTitle={api?.basePath} />
|
||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
||||
</View>
|
||||
<Button className="my-2.5" color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
</View>
|
||||
<MediaProvider>
|
||||
<MediaToggles className="mb-4" />
|
||||
<AudioToggles className="mb-4" />
|
||||
<SubtitleToggles className="mb-4" />
|
||||
</MediaProvider>
|
||||
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
||||
Authorize
|
||||
</Button>
|
||||
</View>
|
||||
<OtherSettings />
|
||||
<DownloadSettings />
|
||||
|
||||
<SettingToggles />
|
||||
<PluginSettings />
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="font-bold text-lg mb-2">Storage</Text>
|
||||
<View className="mb-4 space-y-2">
|
||||
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
|
||||
<Progress.Bar
|
||||
className="bg-gray-100/10"
|
||||
indeterminate={appSizeLoading}
|
||||
color="#9333ea"
|
||||
width={null}
|
||||
height={10}
|
||||
borderRadius={6}
|
||||
borderWidth={0}
|
||||
progress={size?.used}
|
||||
<ListGroup title={"Intro"}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
router.push("/intro/page");
|
||||
}}
|
||||
title={"Show intro"}
|
||||
/>
|
||||
<ListItem
|
||||
textColor="red"
|
||||
onPress={() => {
|
||||
storage.set("hasShownIntro", false);
|
||||
}}
|
||||
title={"Reset intro"}
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<View className="mb-4">
|
||||
<ListGroup title={"Logs"}>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/logs/page")}
|
||||
showArrow
|
||||
title={"Logs"}
|
||||
/>
|
||||
{size && (
|
||||
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
|
||||
)}
|
||||
</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>
|
||||
<ListItem
|
||||
textColor="red"
|
||||
onPress={onClearLogsClicked}
|
||||
title={"Delete All Logs"}
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
<StorageSettings />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
61
app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
Normal file
61
app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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";
|
||||
|
||||
export default function page() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
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 (
|
||||
<View 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">
|
||||
Select the libraries you want to hide from the Library tab and home page
|
||||
sections.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
78
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
78
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
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";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (newVal: string) => {
|
||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||
toast.error("Invalid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
||||
|
||||
updateSettings({
|
||||
optimizedVersionsServerUrl: updatedUrl,
|
||||
});
|
||||
|
||||
return await getStatistics({
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
toast.success("Connected");
|
||||
} else {
|
||||
toast.error("Could not connect");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Could not connect");
|
||||
},
|
||||
});
|
||||
|
||||
const onSave = (newVal: string) => {
|
||||
saveMutation.mutate(newVal);
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// navigation.setOptions({
|
||||
// title: "Optimized Server",
|
||||
// headerRight: () =>
|
||||
// saveMutation.isPending ? (
|
||||
// <ActivityIndicator size={"small"} color={"white"} />
|
||||
// ) : (
|
||||
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
||||
// <Text className="text-blue-500">Save</Text>
|
||||
// </TouchableOpacity>
|
||||
// ),
|
||||
// });
|
||||
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||
|
||||
return (
|
||||
<View className="p-4">
|
||||
<JellyseerrSettings />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
33
app/(auth)/(tabs)/(home)/settings/logs/page.tsx
Normal file
33
app/(auth)/(tabs)/(home)/settings/logs/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useLog } from "@/utils/log";
|
||||
import { ScrollView, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const { logs } = useLog();
|
||||
|
||||
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">No logs available</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
103
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
103
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Linking,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [settings, updateSettings] = 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("Saved");
|
||||
};
|
||||
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => onSave(value)}>
|
||||
<Text className="text-blue-500">Save</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, value]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View className="px-4">
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={"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>
|
||||
</ListGroup>
|
||||
|
||||
<View
|
||||
className={`mt-2 ${
|
||||
settings.searchEngine === "Marlin" ? "" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<View className="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">URL</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className="text-white"
|
||||
placeholder="http(s)://domain.org:port"
|
||||
value={value}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
Enter the URL for the Marlin server. The URL should include http or
|
||||
https and optionally the port.{" "}
|
||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||
Read more about Marlin.
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
80
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
80
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
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";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (newVal: string) => {
|
||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||
toast.error("Invalid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
||||
|
||||
updateSettings({
|
||||
optimizedVersionsServerUrl: updatedUrl,
|
||||
});
|
||||
|
||||
return await getStatistics({
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
toast.success("Connected");
|
||||
} else {
|
||||
toast.error("Could not connect");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Could not connect");
|
||||
},
|
||||
});
|
||||
|
||||
const onSave = (newVal: string) => {
|
||||
saveMutation.mutate(newVal);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: "Optimized Server",
|
||||
headerRight: () =>
|
||||
saveMutation.isPending ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
||||
<Text className="text-blue-500">Save</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||
|
||||
return (
|
||||
<View className="p-4">
|
||||
<OptimizedServerForm
|
||||
value={optimizedVersionsServerUrl}
|
||||
onChangeValue={setOptimizedVersionsServerUrl}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
135
app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
Normal file
135
app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { Linking, Switch, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL(
|
||||
"https://github.com/lostb1t/jellyfin-plugin-collection-import"
|
||||
);
|
||||
};
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: mediaListCollections,
|
||||
isLoading: isLoadingMediaListCollections,
|
||||
} = useQuery({
|
||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
tags: ["sf_promoted"],
|
||||
recursive: true,
|
||||
fields: ["Tags"],
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
return response.data.Items ?? [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View className="px-4 pt-4">
|
||||
<ListGroup title={"Enable plugin"} className="">
|
||||
<ListItem
|
||||
title={"Enable Popular Lists"}
|
||||
onPress={() => {
|
||||
updateSettings({ usePopularPlugin: true });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
value={settings.usePopularPlugin}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ usePopularPlugin: value });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
Popular Lists is a plugin that enables you to show custom Jellyfin lists
|
||||
on the Streamyfin home page.{" "}
|
||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||
Read more about Popular Lists.
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{settings.usePopularPlugin && (
|
||||
<>
|
||||
{!isLoadingMediaListCollections ? (
|
||||
<>
|
||||
{mediaListCollections?.length === 0 ? (
|
||||
<Text className="text-xs opacity-50 p-4">
|
||||
No collections found. Add some in Jellyfin.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<ListGroup title="Media List Collections" className="mt-4">
|
||||
{mediaListCollections?.map((mlc) => (
|
||||
<ListItem key={mlc.Id} title={mlc.Name}>
|
||||
<Switch
|
||||
value={settings.mediaListCollectionIds?.includes(
|
||||
mlc.Id!
|
||||
)}
|
||||
onValueChange={(value) => {
|
||||
if (!settings.mediaListCollectionIds) {
|
||||
updateSettings({
|
||||
mediaListCollectionIds: [mlc.Id!],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateSettings({
|
||||
mediaListCollectionIds:
|
||||
settings.mediaListCollectionIds.includes(
|
||||
mlc.Id!
|
||||
)
|
||||
? settings.mediaListCollectionIds.filter(
|
||||
(id) => id !== mlc.Id
|
||||
)
|
||||
: [
|
||||
...settings.mediaListCollectionIds,
|
||||
mlc.Id!,
|
||||
],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
Select the lists you want displayed on the home screen.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -104,9 +104,12 @@ const page: React.FC = () => {
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import {
|
||||
getMediaInfoApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -48,20 +44,25 @@ const Page: React.FC = () => {
|
||||
});
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const fadeIn = (callback: any) => {
|
||||
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
fadeOut(() => {});
|
||||
@@ -84,14 +85,24 @@ const Page: React.FC = () => {
|
||||
style={[animatedStyle]}
|
||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||
>
|
||||
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
|
||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
|
||||
<View
|
||||
style={{
|
||||
height: item?.Type === "Episode" ? 300 : 450,
|
||||
}}
|
||||
className="bg-transparent rounded-lg mb-4 w-full"
|
||||
></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
||||
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
||||
<View className="flex flex-row space-x-1 mb-8">
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
</View>
|
||||
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} />}
|
||||
</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} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import Cast from "@/components/jellyseerr/Cast";
|
||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import {
|
||||
IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { 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 insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
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 [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const {
|
||||
data: details,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
refetchInterval: 0,
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!!)
|
||||
: jellyseerrApi?.tvDetails(result.id!!);
|
||||
},
|
||||
});
|
||||
|
||||
const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const submitIssue = useCallback(() => {
|
||||
if (result.id && issueType && issueMessage && details) {
|
||||
jellyseerrApi
|
||||
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||
.then(() => {
|
||||
setIssueType(undefined);
|
||||
setIssueMessage(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
});
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const request = useCallback(async () => {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: Number(result.id!!),
|
||||
mediaType: result.mediaType!!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
}
|
||||
|
||||
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 (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className="flex-1 opacity-100"
|
||||
headerHeight={300}
|
||||
headerImage={
|
||||
<View>
|
||||
{result.backdropPath ? (
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
result.backdropPath,
|
||||
"w1920_and_h800_multi_faces"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||
>
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<View className="space-y-4">
|
||||
<View className="px-4">
|
||||
<View className="flex flex-row justify-between w-full">
|
||||
<View className="flex flex-col w-56">
|
||||
<JellyserrRatings result={result as MovieResult | TvResult} />
|
||||
<Text
|
||||
uiTextView
|
||||
selectable
|
||||
className="font-bold text-2xl mb-1"
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
<Text className="opacity-50">{releaseYear}</Text>
|
||||
</View>
|
||||
<Image
|
||||
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
source={{
|
||||
uri: posterSrc,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{isLoading || isFetching ? (
|
||||
<Button loading={true} disabled={true} color="purple"></Button>
|
||||
) : canRequest ? (
|
||||
<Button color="purple" onPress={request}>
|
||||
Request
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
|
||||
color="transparent"
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons name="warning-outline" size={24} color="white" />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
Report issue
|
||||
</Button>
|
||||
)}
|
||||
<OverviewText text={result.overview} className="mt-4" />
|
||||
</View>
|
||||
|
||||
{result.mediaType === MediaType.TV && (
|
||||
<JellyseerrSeasons
|
||||
isLoading={isLoading || isFetching}
|
||||
result={result as TvResult}
|
||||
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>
|
||||
</ParallaxScrollView>
|
||||
<RequestModal
|
||||
ref={advancedReqModalRef}
|
||||
title={mediaTitle}
|
||||
id={result.id!!}
|
||||
type={result.mediaType as MediaType}
|
||||
isAnime={isAnime}
|
||||
onRequested={() => {
|
||||
advancedReqModalRef?.current?.close()
|
||||
refetch()
|
||||
}}
|
||||
/>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
Whats wrong?
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 items-start">
|
||||
<View className="flex flex-col">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col">
|
||||
<Text className="opacity-50 mb-1 text-xs">
|
||||
Issue Type
|
||||
</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}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: "Select an issue"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side="bottom"
|
||||
align="center"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>Types</DropdownMenu.Label>
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value], idx) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() =>
|
||||
setIssueType(key as unknown as IssueType)
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{value}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
||||
<BottomSheetTextInput
|
||||
multiline
|
||||
maxLength={254}
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode="always"
|
||||
placeholder="(optional) Describe the issue..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
// Issue with multiline + Textinput inside a portal
|
||||
// https://github.com/callstack/react-native-paper/issues/1668
|
||||
defaultValue={issueMessage}
|
||||
onChangeText={setIssueMessage}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||
Submit
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,107 @@
|
||||
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";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
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="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">
|
||||
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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Button, Dimensions, ScrollView, View } from "react-native";
|
||||
import {
|
||||
Button,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
@@ -78,8 +86,6 @@ export default function page() {
|
||||
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
|
||||
|
||||
const [scrollX, setScrollX] = useState(0);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
@@ -100,24 +106,15 @@ export default function page() {
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-row bg-neutral-800 w-full items-end">
|
||||
<Button
|
||||
title="Previous"
|
||||
onPress={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
/>
|
||||
<Button
|
||||
title="Next"
|
||||
onPress={handleNextPage}
|
||||
disabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<PageButtons
|
||||
currentPage={currentPage}
|
||||
onPrevPage={handlePrevPage}
|
||||
onNextPage={handleNextPage}
|
||||
isNextDisabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
|
||||
<View className="flex flex-row">
|
||||
<View className="flex flex-col w-[64px]">
|
||||
@@ -166,3 +163,57 @@ export default function page() {
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageButtonsProps {
|
||||
currentPage: number;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
isNextDisabled: boolean;
|
||||
}
|
||||
|
||||
const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
currentPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
isNextDisabled,
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
||||
<TouchableOpacity
|
||||
onPress={onPrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color={currentPage === 1 ? "gray" : "white"}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-1 ${
|
||||
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-white">Page {currentPage}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onNextPage}
|
||||
disabled={isNextDisabled}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Text
|
||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||
>
|
||||
Next
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={24}
|
||||
color={isNextDisabled ? "gray" : "white"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -5,10 +5,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
View
|
||||
} from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
@@ -27,9 +24,6 @@ export default function page() {
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<ScrollingCollectionList
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import {useLocalSearchParams, useNavigation} from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {useEffect} from "react";
|
||||
import { useMemo } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import {DownloadItems} from "@/components/DownloadItem";
|
||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {getTvShowsApi} from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
@@ -36,7 +36,6 @@ const page: React.FC = () => {
|
||||
userId: user?.Id,
|
||||
itemId: seriesId,
|
||||
}),
|
||||
enabled: !!seriesId && !!api,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -60,7 +59,7 @@ const page: React.FC = () => {
|
||||
[item]
|
||||
);
|
||||
|
||||
const {data: allEpisodes, isLoading} = useQuery({
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["AllEpisodes", item?.Id],
|
||||
queryFn: async () => {
|
||||
const res = await getTvShowsApi(api!).getEpisodes({
|
||||
@@ -69,34 +68,42 @@ const page: React.FC = () => {
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
return res?.data.Items || []
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item?.Id
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
|
||||
headerRight: () =>
|
||||
!isLoading &&
|
||||
item &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<AddToFavorites item={item} type="series" />
|
||||
<DownloadItems
|
||||
size="large"
|
||||
title="Download Series"
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
|
||||
<Ionicons name="download" size={22} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={24}
|
||||
color="#9333ea"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
)
|
||||
})
|
||||
}, [allEpisodes, isLoading]);
|
||||
|
||||
if (!item || !backdropUrl)
|
||||
return null;
|
||||
),
|
||||
});
|
||||
}, [allEpisodes, isLoading, item]);
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
@@ -130,10 +137,7 @@ const page: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4">
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
</View>
|
||||
<SeriesHeader item={item} />
|
||||
<View className="mb-4">
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
@@ -41,8 +42,6 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { libraryId } = searchParams as { libraryId: string };
|
||||
@@ -141,6 +140,20 @@ const Page = () => {
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !library) return null;
|
||||
|
||||
let itemType: BaseItemKind | undefined;
|
||||
|
||||
// This fix makes sure to only return 1 type of items, if defined.
|
||||
// This is because the underlying directory some times contains other types, and we don't want to show them.
|
||||
if (library.CollectionType === "movies") {
|
||||
itemType = "Movie";
|
||||
} else if (library.CollectionType === "tvshows") {
|
||||
itemType = "Series";
|
||||
} else if (library.CollectionType === "boxsets") {
|
||||
itemType = "BoxSet";
|
||||
} else if (library.CollectionType === "music") {
|
||||
itemType = "MusicAlbum";
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
@@ -149,12 +162,14 @@ const Page = () => {
|
||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0]],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
recursive: false,
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
imageTypeLimit: 1,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
includeItemTypes: itemType ? [itemType] : undefined,
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
|
||||
@@ -19,6 +19,9 @@ export default function IndexLayout() {
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Library",
|
||||
headerBlurEffect: "prominent",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
@@ -23,20 +23,20 @@ export default function index() {
|
||||
const { data, isLoading: isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
const response = await getUserViewsApi(api!).getUserViews({
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000 * 60,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const libraries = useMemo(
|
||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||
[data, settings?.hiddenLibraries]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
for (const item of data || []) {
|
||||
queryClient.prefetchQuery({
|
||||
@@ -63,7 +63,7 @@ export default function index() {
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!data)
|
||||
if (!libraries)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
||||
@@ -81,7 +81,7 @@ export default function index() {
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
data={data}
|
||||
data={libraries}
|
||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
ItemSeparatorComponent={() =>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import {
|
||||
commonScreenOptions,
|
||||
nestedTabPageScreenOptions,
|
||||
} from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
@@ -11,6 +14,9 @@ export default function SearchLayout() {
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Search",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
@@ -29,6 +35,10 @@ export default function SearchLayout() {
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
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 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 { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
@@ -30,6 +33,8 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
"Avengers",
|
||||
@@ -45,6 +50,7 @@ export default function search() {
|
||||
|
||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
@@ -53,6 +59,7 @@ export default function search() {
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
@@ -83,6 +90,7 @@ export default function search() {
|
||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||
} else {
|
||||
if (!settings?.marlinServerUrl) return [];
|
||||
|
||||
const url = `${
|
||||
settings.marlinServerUrl
|
||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
@@ -90,6 +98,7 @@ export default function search() {
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) return [];
|
||||
@@ -132,7 +141,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Movie"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: series, isFetching: l2 } = useQuery({
|
||||
@@ -142,7 +151,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Series"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: episodes, isFetching: l3 } = useQuery({
|
||||
@@ -152,7 +161,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Episode"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: collections, isFetching: l7 } = useQuery({
|
||||
@@ -162,7 +171,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["BoxSet"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: actors, isFetching: l8 } = useQuery({
|
||||
@@ -172,7 +181,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Person"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: artists, isFetching: l4 } = useQuery({
|
||||
@@ -182,7 +191,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["MusicArtist"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: albums, isFetching: l5 } = useQuery({
|
||||
@@ -192,7 +201,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["MusicAlbum"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: songs, isFetching: l6 } = useQuery({
|
||||
@@ -202,7 +211,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Audio"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const noResults = useMemo(() => {
|
||||
@@ -232,7 +241,7 @@ export default function search() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col pt-2">
|
||||
<View className="flex flex-col">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
@@ -245,217 +254,192 @@ export default function search() {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{!!q && (
|
||||
<View className="px-4 flex flex-col space-y-2">
|
||||
<Text className="text-neutral-500 ">
|
||||
Results for <Text className="text-purple-600">{q}</Text>
|
||||
</Text>
|
||||
{jellyseerrApi && (
|
||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text="Library"
|
||||
textClass="p-1"
|
||||
className={
|
||||
searchType === "Library" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text="Discover"
|
||||
textClass="p-1"
|
||||
className={
|
||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header="Series"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header="Episodes"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header="Collections"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header="Actors"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
header="Artists"
|
||||
renderItem={(item) => (
|
||||
<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) => (
|
||||
<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) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
{loading ? (
|
||||
<View className="mt-4 flex justify-center items-center">
|
||||
<Loader />
|
||||
|
||||
<View className="mt-2">
|
||||
<LoadingSkeleton isLoading={loading} />
|
||||
</View>
|
||||
|
||||
{searchType === "Library" ? (
|
||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header="Series"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header="Episodes"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header="Collections"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header="Actors"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
header="Artists"
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
) : noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className="text-center text-lg font-bold mt-4">
|
||||
No results found for
|
||||
</Text>
|
||||
<Text className="text-xs text-purple-600 text-center">
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setSearch(e)}
|
||||
key={e}
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-purple-600">{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
) : (
|
||||
<JellyserrIndexPage searchQuery={debouncedSearch} />
|
||||
)}
|
||||
|
||||
{searchType === "Library" && (
|
||||
<>
|
||||
{!loading && noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className="text-center text-lg font-bold mt-4">
|
||||
No results found for
|
||||
</Text>
|
||||
<Text className="text-xs text-purple-600 text-center">
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setSearch(e)}
|
||||
key={e}
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-purple-600">{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
ids?: string[] | null;
|
||||
renderItem: (item: BaseItemDto) => React.ReactNode;
|
||||
header?: string;
|
||||
};
|
||||
|
||||
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
||||
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) 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.map((item) => renderItem(item))}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { withLayoutContext } from "expo-router";
|
||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||
|
||||
import {
|
||||
createNativeBottomTabNavigator,
|
||||
@@ -13,12 +13,13 @@ const { Navigator } = createNativeBottomTabNavigator();
|
||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
BottomTabNavigationOptions,
|
||||
@@ -29,11 +30,28 @@ export const NativeTabs = withLayoutContext<
|
||||
|
||||
export default function TabLayout() {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||
if (!hasShownIntro) {
|
||||
const timer = setTimeout(() => {
|
||||
router.push("/intro/page");
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden={false} style="light" />
|
||||
<NativeTabs
|
||||
sidebarAdaptable
|
||||
sidebarAdaptable={false}
|
||||
ignoresTopSafeArea
|
||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
||||
tabBarActiveTintColor={Colors.primary}
|
||||
@@ -48,7 +66,10 @@ export default function TabLayout() {
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/house.fill.png")
|
||||
: () => ({ sfSymbol: "house" }),
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "house.fill" }
|
||||
: { sfSymbol: "house" },
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -59,7 +80,26 @@ export default function TabLayout() {
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/magnifyingglass.png")
|
||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "magnifyingglass" }
|
||||
: { sfSymbol: "magnifyingglass" },
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(favorites)"
|
||||
options={{
|
||||
title: "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
|
||||
@@ -70,7 +110,10 @@ export default function TabLayout() {
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/server.rack.png")
|
||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "rectangle.stack.fill" }
|
||||
: { sfSymbol: "rectangle.stack" },
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -81,8 +124,11 @@ export default function TabLayout() {
|
||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? () => require("@/assets/icons/list.png")
|
||||
: () => ({ sfSymbol: "list.dash" }),
|
||||
? ({ focused }) => require("@/assets/icons/list.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "list.dash.fill" }
|
||||
: { sfSymbol: "list.dash" },
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
@@ -43,10 +43,12 @@ import {
|
||||
View,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import settings from "../(tabs)/(home)/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
@@ -67,9 +69,11 @@ export default function page() {
|
||||
const { getDownloadedItem } = useDownload();
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
lightHapticFeedback();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
@@ -103,7 +107,6 @@ export default function page() {
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
const item = await getDownloadedItem(itemId);
|
||||
if (item) return item.item;
|
||||
@@ -127,7 +130,6 @@ export default function page() {
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
const data = await getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return null;
|
||||
@@ -176,7 +178,7 @@ export default function page() {
|
||||
const togglePlay = useCallback(async () => {
|
||||
if (!api) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
lightHapticFeedback();
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
|
||||
@@ -194,8 +196,6 @@ export default function page() {
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Actually marked as paused");
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
if (!offline && stream) {
|
||||
@@ -281,13 +281,6 @@ export default function page() {
|
||||
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
console.log(
|
||||
"onProgress ~",
|
||||
currentTimeInTicks,
|
||||
isPlaying,
|
||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||
);
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
@@ -345,7 +338,6 @@ export default function page() {
|
||||
React.useCallback(() => {
|
||||
return async () => {
|
||||
stop();
|
||||
console.log("Unmounted");
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
@@ -355,10 +347,8 @@ export default function page() {
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||
console.log("App has come to the foreground!");
|
||||
// Handle app coming to the foreground
|
||||
} else if (nextAppState.match(/inactive|background/)) {
|
||||
console.log("App has gone to the background!");
|
||||
// Handle app going to the background
|
||||
if (videoRef.current && videoRef.current.pause) {
|
||||
videoRef.current.pause();
|
||||
@@ -424,6 +414,8 @@ export default function page() {
|
||||
}
|
||||
}
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
@@ -448,7 +440,8 @@ export default function page() {
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
opacity: showControls ? 0.5 : 1,
|
||||
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||
}}
|
||||
>
|
||||
<VlcPlayerView
|
||||
@@ -501,7 +494,7 @@ export default function page() {
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||
offline={false}
|
||||
offline={offline}
|
||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { Image } from "expo-image";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
@@ -45,6 +45,8 @@ export default function page() {
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
@@ -124,7 +126,7 @@ export default function page() {
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ticks: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
lightHapticFeedback();
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
@@ -169,18 +171,15 @@ export default function page() {
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
@@ -48,6 +48,7 @@ const Player = () => {
|
||||
|
||||
const firstTime = useRef(true);
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
@@ -58,7 +59,7 @@ const Player = () => {
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
lightHapticFeedback();
|
||||
}, []);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
@@ -167,7 +168,7 @@ const Player = () => {
|
||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
lightHapticFeedback();
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
@@ -260,13 +261,6 @@ const Player = () => {
|
||||
progress.value = ticks;
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
|
||||
console.log(
|
||||
"onProgress ~",
|
||||
ticks,
|
||||
isPlaying,
|
||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||
);
|
||||
|
||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||
// TODO: since playable duration is always 0 then.
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
@@ -339,11 +333,7 @@ const Player = () => {
|
||||
|
||||
// Most likely the subtitle is burned in.
|
||||
if (embeddedTrackIndex === -1) return;
|
||||
console.log(
|
||||
"Setting selected text track",
|
||||
subtitleIndex,
|
||||
embeddedTrackIndex
|
||||
);
|
||||
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: embeddedTrackIndex,
|
||||
@@ -398,7 +388,6 @@ const Player = () => {
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
opacity: showControls ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{videoSource ? (
|
||||
@@ -439,7 +428,6 @@ const Player = () => {
|
||||
setIsBuffering(e.isBuffering);
|
||||
}}
|
||||
onAudioTracks={(e) => {
|
||||
console.log("onAudioTracks: ", e.audioTracks);
|
||||
setAudioTracks(
|
||||
e.audioTracks.map((t) => ({
|
||||
index: t.index,
|
||||
@@ -493,7 +481,6 @@ const Player = () => {
|
||||
}}
|
||||
getAudioTracks={getAudioTracks}
|
||||
setAudioTrack={(i) => {
|
||||
console.log("setAudioTrack ~", i);
|
||||
setSelectedAudioTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "@/augmentations";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
getOrSetDeviceId,
|
||||
@@ -35,7 +37,7 @@ import * as SplashScreen from "expo-splash-screen";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
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 { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
@@ -336,7 +338,11 @@ function Layout() {
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
|
||||
301
app/login.tsx
301
app/login.tsx
@@ -1,19 +1,26 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
Ionicons,
|
||||
MaterialCommunityIcons,
|
||||
MaterialIcons,
|
||||
} from "@expo/vector-icons";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
@@ -37,7 +44,6 @@ const Login: React.FC = () => {
|
||||
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -65,6 +71,25 @@ const Login: React.FC = () => {
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: serverName,
|
||||
headerLeft: () =>
|
||||
api?.basePath ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||
<Text className="ml-2 text-purple-600">Change server</Text>
|
||||
</TouchableOpacity>
|
||||
) : null,
|
||||
});
|
||||
}, [serverName, navigation, api?.basePath]);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
@@ -76,9 +101,9 @@ const Login: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
Alert.alert("Connection failed", error.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
Alert.alert("Connection failed", "An unexpected error occurred");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -102,7 +127,7 @@ const Login: React.FC = () => {
|
||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
||||
* - Logs errors and timeout information to the console.
|
||||
*/
|
||||
async function checkUrl(url: string) {
|
||||
const checkUrl = useCallback(async (url: string) => {
|
||||
setLoadingServerCheck(true);
|
||||
|
||||
try {
|
||||
@@ -112,15 +137,18 @@ const Login: React.FC = () => {
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
|
||||
setServerName(data.ServerName || "");
|
||||
return url;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoadingServerCheck(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles the connection attempt to a Jellyfin server.
|
||||
@@ -138,7 +166,7 @@ const Login: React.FC = () => {
|
||||
* - 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();
|
||||
|
||||
const result = await checkUrl(url);
|
||||
@@ -152,7 +180,7 @@ const Login: React.FC = () => {
|
||||
}
|
||||
|
||||
setServer({ address: url });
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
try {
|
||||
@@ -169,142 +197,131 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (api?.basePath) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
>
|
||||
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20">
|
||||
<View className="mb-4">
|
||||
<Text className="text-3xl font-bold mb-1">
|
||||
{serverName || "Streamyfin"}
|
||||
</Text>
|
||||
<View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
|
||||
<Text className="">URL</Text>
|
||||
<Text numberOfLines={1} className="shrink">
|
||||
{api.basePath}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="arrow-back-outline"
|
||||
size={18}
|
||||
color={"white"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Change server
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="text-red-600 mb-2">{error}</Text>
|
||||
</View>
|
||||
|
||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
|
||||
<Button
|
||||
color="black"
|
||||
onPress={handleQuickConnect}
|
||||
className="w-full mb-2"
|
||||
>
|
||||
Use Quick Connect
|
||||
</Button>
|
||||
<Button onPress={handleLogin} loading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||
<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">
|
||||
Connect to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Server URL"
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
</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>
|
||||
{api?.basePath ? (
|
||||
<>
|
||||
<View className="flex flex-col h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20 w-full">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
Log in
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{" to "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400">
|
||||
{api.basePath}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
className="flex-1 mr-2"
|
||||
>
|
||||
Log in
|
||||
</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 className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View className="flex flex-col h-full 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
|
||||
aria-label="Server URL"
|
||||
placeholder="http(s)://your-server.com"
|
||||
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"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<PreviousServersList
|
||||
onServerSelect={(s) => {
|
||||
handleConnect(s.address);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</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 |
65
assets/images/not-rotten-tomatoes.svg
Normal file
65
assets/images/not-rotten-tomatoes.svg
Normal file
@@ -0,0 +1,65 @@
|
||||
<svg
|
||||
type="certified"
|
||||
viewBox="0 0 80 80"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<g transform="translate(2.29, 0)">
|
||||
<path
|
||||
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
|
||||
id="Fill-2"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<mask id="mask-2" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
|
||||
fill="#FFD700"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
|
||||
fill="#FA6E0F"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
<g transform="translate(0, 20.57)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
|
||||
fill="#FA3200"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
</g>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
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");
|
||||
};
|
||||
3
augmentations/index.ts
Normal file
3
augmentations/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./mmkv";
|
||||
export * from "./number";
|
||||
export * from "./string";
|
||||
22
augmentations/mmkv.ts
Normal file
22
augmentations/mmkv.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {MMKV} from "react-native-mmkv";
|
||||
|
||||
declare module "react-native-mmkv" {
|
||||
interface MMKV {
|
||||
get<T>(key: string): T | undefined
|
||||
setAny(key: string, value: any | undefined): void
|
||||
}
|
||||
}
|
||||
|
||||
MMKV.prototype.get = function <T> (key: string): T | undefined {
|
||||
const serializedItem = this.getString(key);
|
||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||
}
|
||||
|
||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||
if (value === undefined) {
|
||||
this.delete(key)
|
||||
}
|
||||
else {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
35
augmentations/number.ts
Normal file
35
augmentations/number.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
declare global {
|
||||
interface Number {
|
||||
bytesToReadable(decimals?: number): string;
|
||||
secondsToMilliseconds(): number;
|
||||
minutesToMilliseconds(): number;
|
||||
hoursToMilliseconds(): number;
|
||||
}
|
||||
}
|
||||
|
||||
Number.prototype.bytesToReadable = function (decimals: number = 2) {
|
||||
const bytes = this.valueOf();
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
Number.prototype.secondsToMilliseconds = function () {
|
||||
return this.valueOf() * 1000;
|
||||
};
|
||||
|
||||
Number.prototype.minutesToMilliseconds = function () {
|
||||
return this.valueOf() * (60).secondsToMilliseconds();
|
||||
};
|
||||
|
||||
Number.prototype.hoursToMilliseconds = function () {
|
||||
return this.valueOf() * (60).minutesToMilliseconds();
|
||||
};
|
||||
|
||||
export {};
|
||||
16
augmentations/string.ts
Normal file
16
augmentations/string.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
declare global {
|
||||
interface String {
|
||||
toTitle(): string;
|
||||
}
|
||||
}
|
||||
|
||||
String.prototype.toTitle = function () {
|
||||
return this
|
||||
.replaceAll("_", " ")
|
||||
.replace(
|
||||
/\w\S*/g,
|
||||
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,10 @@ export const BITRATES: Bitrate[] = [
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "1 Mb/s",
|
||||
value: 1000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
@@ -37,31 +37,35 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
case "red":
|
||||
return "bg-red-600";
|
||||
case "black":
|
||||
return "bg-neutral-900 border border-neutral-800";
|
||||
return "bg-neutral-900";
|
||||
case "transparent":
|
||||
return "bg-transparent";
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className={`
|
||||
p-3 rounded-xl items-center justify-center
|
||||
${loading || (disabled && "opacity-50")}
|
||||
${(loading || disabled) && "opacity-50"}
|
||||
${colorClasses}
|
||||
${className}
|
||||
`}
|
||||
onPress={() => {
|
||||
if (!loading && !disabled && onPress) {
|
||||
onPress();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
lightHapticFeedback();
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader />
|
||||
<View className="p-0.5">
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
className={`
|
||||
|
||||
@@ -10,6 +10,7 @@ import GoogleCast, {
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
width?: number;
|
||||
@@ -33,6 +34,7 @@ export const Chromecast: React.FC<Props> = ({
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!discoveryManager) {
|
||||
console.warn("DiscoveryManager is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,51 +55,32 @@ export const Chromecast: React.FC<Props> = ({
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
<AndroidCastButton />
|
||||
</>
|
||||
);
|
||||
|
||||
if (Platform.OS === "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<RoundButton
|
||||
size="large"
|
||||
className="mr-2"
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</RoundButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<RoundButton
|
||||
size="large"
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<BlurView
|
||||
intensity={100}
|
||||
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</BlurView>
|
||||
<AndroidCastButton />
|
||||
</TouchableOpacity>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { Href, router, useFocusEffect } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Alert, View, ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
@@ -30,18 +30,25 @@ import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
MissingDownloadIconComponent: () => React.ReactElement;
|
||||
DownloadedIconComponent: () => React.ReactElement;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
items,
|
||||
MissingDownloadIconComponent,
|
||||
DownloadedIconComponent,
|
||||
title = "Download",
|
||||
subtitle = "",
|
||||
size = "default",
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -71,9 +78,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
[settings]
|
||||
);
|
||||
|
||||
/**
|
||||
* Bottom sheet
|
||||
*/
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const handlePresentModalPress = useCallback(() => {
|
||||
@@ -86,18 +90,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
// region computed
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
const pendingItems = useMemo(
|
||||
|
||||
const itemsNotDownloaded = useMemo(
|
||||
() =>
|
||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||
[items, downloadedFiles]
|
||||
);
|
||||
const isDownloaded = useMemo(() => {
|
||||
if (!downloadedFiles) return false;
|
||||
return pendingItems.length == 0;
|
||||
}, [downloadedFiles, pendingItems]);
|
||||
|
||||
const allItemsDownloaded = useMemo(() => {
|
||||
if (items.length === 0) return false;
|
||||
return itemsNotDownloaded.length === 0;
|
||||
}, [items, itemsNotDownloaded]);
|
||||
const itemsProcesses = useMemo(
|
||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||
[processes, itemIds]
|
||||
@@ -116,13 +120,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
const itemsQueued = useMemo(() => {
|
||||
return (
|
||||
pendingItems.length > 0 &&
|
||||
pendingItems.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||
itemsNotDownloaded.length > 0 &&
|
||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||
);
|
||||
}, [queue, pendingItems]);
|
||||
// endregion computed
|
||||
|
||||
// region helper functions
|
||||
}, [queue, itemsNotDownloaded]);
|
||||
const navigateToDownloads = () => router.push("/downloads");
|
||||
|
||||
const onDownloadedPress = () => {
|
||||
@@ -141,17 +142,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (pendingItems.some((i) => !i.Id)) {
|
||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
if (usingOptimizedServer) initiateDownload(...pendingItems);
|
||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||
else {
|
||||
queueActions.enqueue(
|
||||
queue,
|
||||
setQueue,
|
||||
...pendingItems.map((item) => ({
|
||||
...itemsNotDownloaded.map((item) => ({
|
||||
id: item.Id!,
|
||||
execute: async () => await initiateDownload(item),
|
||||
item,
|
||||
@@ -164,27 +165,22 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
}, [
|
||||
queue,
|
||||
setQueue,
|
||||
pendingItems,
|
||||
itemsNotDownloaded,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
|
||||
// Need to be reference at the time async lambda is created for initiateDownload
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Start download
|
||||
*/
|
||||
const initiateDownload = useCallback(
|
||||
async (...items: BaseItemDto[]) => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
items.some((p) => !p.Id) ||
|
||||
(pendingItems.length === 1 && !selectedMediaSource?.Id)
|
||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||
) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
@@ -195,7 +191,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||
|
||||
for (const item of items) {
|
||||
if (pendingItems.length > 1) {
|
||||
if (itemsNotDownloaded.length > 1) {
|
||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||
item,
|
||||
settings!
|
||||
@@ -238,7 +234,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
pendingItems,
|
||||
itemsNotDownloaded,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
@@ -260,58 +256,61 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
),
|
||||
[]
|
||||
);
|
||||
// endregion helper functions
|
||||
|
||||
// Allow to select & set settings for single download
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
if (pendingItems.length !== 1) return;
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
|
||||
// 4. Set states
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, pendingItems, settings])
|
||||
}, [items, itemsNotDownloaded, settings])
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
{processes && itemsProcesses.length > 0 ? (
|
||||
<TouchableOpacity onPress={navigateToDownloads}>
|
||||
{progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : itemsQueued ? (
|
||||
<TouchableOpacity onPress={navigateToDownloads}>
|
||||
<Ionicons name="hourglass" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : isDownloaded ? (
|
||||
<TouchableOpacity onPress={onDownloadedPress}>
|
||||
{DownloadedIconComponent()}
|
||||
</TouchableOpacity>
|
||||
const renderButtonContent = () => {
|
||||
if (processes && itemsProcesses.length > 0) {
|
||||
return progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<TouchableOpacity onPress={handlePresentModalPress}>
|
||||
{MissingDownloadIconComponent()}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else if (itemsQueued) {
|
||||
return <Ionicons name="hourglass" size={24} color="white" />;
|
||||
} else if (allItemsDownloaded) {
|
||||
return <DownloadedIconComponent />;
|
||||
} else {
|
||||
return <MissingDownloadIconComponent />;
|
||||
}
|
||||
};
|
||||
|
||||
const onButtonPress = () => {
|
||||
if (processes && itemsProcesses.length > 0) {
|
||||
navigateToDownloads();
|
||||
} else if (itemsQueued) {
|
||||
navigateToDownloads();
|
||||
} else if (allItemsDownloaded) {
|
||||
onDownloadedPress();
|
||||
} else {
|
||||
handlePresentModalPress();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton size={size} onPress={onButtonPress}>
|
||||
{renderButtonContent()}
|
||||
</RoundButton>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
@@ -326,16 +325,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<Text className="font-bold text-2xl text-neutral-10">
|
||||
Download options
|
||||
</Text>
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="text-neutral-300">
|
||||
{subtitle || `Download ${itemsNotDownloaded.length} items`}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 w-full items-start">
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={setMaxBitrate}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
{pendingItems.length === 1 && (
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
@@ -380,11 +384,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({
|
||||
item,
|
||||
}) => {
|
||||
export const DownloadSingleItem: React.FC<{
|
||||
size?: "default" | "large";
|
||||
item: BaseItemDto;
|
||||
}> = ({ item, size = "default" }) => {
|
||||
return (
|
||||
<DownloadItems
|
||||
size={size}
|
||||
title="Download Episode"
|
||||
subtitle={item.Name!}
|
||||
items={[item]}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
// GenreTags.tsx
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface GenreTagsProps {
|
||||
genres?: string[];
|
||||
interface TagProps {
|
||||
tags?: string[];
|
||||
textClass?: ViewProps["className"]
|
||||
}
|
||||
|
||||
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
||||
if (!genres || genres.length === 0) return null;
|
||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
|
||||
text,
|
||||
textClass,
|
||||
textStyle,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||
<Text className={textClass} style={textStyle}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-row flex-wrap mt-2">
|
||||
{genres.map((genre, idx) => (
|
||||
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1 mr-1">
|
||||
<Text className="text-xs">{genre}</Text>
|
||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||
{tags.map((tag, idx) => (
|
||||
<View key={idx}>
|
||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
|
||||
return (
|
||||
<View className="mt-2">
|
||||
<Tags tags={genres}/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,12 +13,13 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||
<View className="mt-2 flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.SeriesName}
|
||||
<Text numberOfLines={1} className="">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||
{item.Name}
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -15,12 +15,12 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
@@ -31,10 +31,10 @@ import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
@@ -68,7 +68,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
console.log(defaultAudioIndex, defaultSubtitleIndex);
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
@@ -90,8 +89,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
<Chromecast background="blur" width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadSingleItem item={item} />
|
||||
<DownloadSingleItem item={item} size="large" />
|
||||
<PlayedStatus item={item} />
|
||||
<AddToFavorites item={item} type="item" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -220,7 +220,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
className="mr-1"
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
console.log(val);
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
@@ -286,8 +285,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<View className="h-16"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { Ratings } from "./Ratings";
|
||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import React from "react";
|
||||
import { ItemActions } from "./series/SeriesActions";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -27,7 +28,10 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
return (
|
||||
<View className="flex flex-col" {...props}>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Ratings item={item} className="mb-2" />
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Ratings item={item} className="mb-2" />
|
||||
<ItemActions item={item} />
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
<>
|
||||
<EpisodeTitleHeader item={item} />
|
||||
|
||||
@@ -124,11 +124,11 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
{audioStreams.map((audioStreams, index) => (
|
||||
<View className="flex flex-col">
|
||||
<View key={index} className="flex flex-col">
|
||||
<Text className="mb-3 text-neutral-400 text-xs">
|
||||
{audioStreams.DisplayTitle}
|
||||
</Text>
|
||||
<View key={index} className="flex-row flex-wrap gap-2">
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
@@ -175,6 +175,8 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
) as MediaStream;
|
||||
}, [source.MediaStreams]);
|
||||
|
||||
if (!videoStream) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
|
||||
@@ -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">
|
||||
{subTitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{iconAfter}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -29,6 +29,27 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
[item, selected]
|
||||
);
|
||||
|
||||
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 (
|
||||
<View
|
||||
className="flex shrink"
|
||||
@@ -63,9 +84,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||
source.Size
|
||||
)}`}
|
||||
{`${name(source.Name)}`}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
@@ -74,9 +93,3 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const name = (name?: string | null) => {
|
||||
if (name && name.length > 40)
|
||||
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
|
||||
return name;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { type PropsWithChildren, type ReactElement } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
@@ -13,6 +13,7 @@ interface Props extends ViewProps {
|
||||
logo?: ReactElement;
|
||||
episodePoster?: ReactElement;
|
||||
headerHeight?: number;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
}
|
||||
|
||||
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
@@ -21,6 +22,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
episodePoster,
|
||||
headerHeight = 400,
|
||||
logo,
|
||||
onEndReached,
|
||||
...props
|
||||
}: Props) => {
|
||||
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 (
|
||||
<View className="flex-1" {...props}>
|
||||
<Animated.ScrollView
|
||||
@@ -55,6 +62,10 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
}}
|
||||
ref={scrollRef}
|
||||
scrollEventThrottle={16}
|
||||
onScroll={e => {
|
||||
if (isCloseToBottom(e.nativeEvent))
|
||||
onEndReached?.()
|
||||
}}
|
||||
>
|
||||
{logo && (
|
||||
<View
|
||||
|
||||
@@ -32,6 +32,7 @@ import Animated, {
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
@@ -63,6 +64,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings] = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string, bitrateValue: number | undefined) => {
|
||||
@@ -78,6 +80,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const onPress = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = () => {
|
||||
@@ -46,44 +39,16 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||
|
||||
return (
|
||||
<View
|
||||
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
{item.UserData?.Played ? (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
await markAsNotPlayed({
|
||||
api: api,
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
await markAsPlayed({
|
||||
api: api,
|
||||
item: item,
|
||||
userId: user?.Id,
|
||||
});
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
fillColor={item.UserData?.Played ? "primary" : undefined}
|
||||
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
||||
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
||||
size="large"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
48
components/PreviousServersList.tsx
Normal file
48
components/PreviousServersList.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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";
|
||||
|
||||
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]);
|
||||
|
||||
if (!previousServers.length) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title="previous servers" className="mt-4">
|
||||
{previousServers.map((s) => (
|
||||
<ListItem
|
||||
key={s.address}
|
||||
onPress={() => onServerSelect(s)}
|
||||
title={s.address}
|
||||
showArrow
|
||||
/>
|
||||
))}
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
setPreviousServers("[]");
|
||||
}}
|
||||
title={"Clear"}
|
||||
textColor="red"
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,10 @@ import { View, ViewProps } from "react-native";
|
||||
import { Badge } from "./Badge";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -17,7 +21,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
)}
|
||||
{item.CommunityRating && (
|
||||
<Badge
|
||||
text={item.CommunityRating}
|
||||
text={item.CommunityRating.toFixed(1)}
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="star" size={14} color="gold" />}
|
||||
/>
|
||||
@@ -28,7 +32,11 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
source={require("@/assets/images/rotten-tomatoes.png")}
|
||||
source={
|
||||
item.CriticRating < 60
|
||||
? require("@/assets/images/rotten-tomatoes.png")
|
||||
: require("@/assets/images/not-rotten-tomatoes.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
@@ -40,3 +48,86 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
|
||||
result,
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieRatings(result.id)
|
||||
: jellyseerrApi?.tvRatings(result.id);
|
||||
},
|
||||
staleTime: (5).minutesToMilliseconds(),
|
||||
retry: false,
|
||||
enabled: !!jellyseerrApi,
|
||||
});
|
||||
|
||||
return (
|
||||
(isLoading ||
|
||||
!!result.voteCount ||
|
||||
(data?.criticsRating && !!data?.criticsScore) ||
|
||||
(data?.audienceRating && !!data?.audienceScore)) && (
|
||||
<View className="flex flex-row flex-wrap space-x-1">
|
||||
{data?.criticsRating && !!data?.criticsScore && (
|
||||
<Badge
|
||||
text={`${data.criticsScore}%`}
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
source={
|
||||
data?.criticsRating === "Rotten"
|
||||
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{data?.audienceRating && !!data?.audienceScore && (
|
||||
<Badge
|
||||
text={`${data.audienceScore}%`}
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
source={
|
||||
data?.audienceRating === "Spilled"
|
||||
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!result.voteCount && (
|
||||
<Badge
|
||||
text={`${Math.round(result.voteAverage * 10)}%`}
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
115
components/RoundButton.tsx
Normal file
115
components/RoundButton.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { PropsWithChildren } from "react";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
} from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
onPress?: () => void;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
background?: boolean;
|
||||
size?: "default" | "large";
|
||||
fillColor?: "primary";
|
||||
hapticFeedback?: boolean;
|
||||
}
|
||||
|
||||
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
background = true,
|
||||
icon,
|
||||
onPress,
|
||||
children,
|
||||
size = "default",
|
||||
fillColor,
|
||||
hapticFeedback = true,
|
||||
...props
|
||||
}) => {
|
||||
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
||||
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const handlePress = () => {
|
||||
if (hapticFeedback) {
|
||||
lightHapticFeedback();
|
||||
}
|
||||
onPress?.();
|
||||
};
|
||||
|
||||
if (fillColor)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (background === false)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (Platform.OS === "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||
fillColor ? fillColorClass : "bg-neutral-800/80"
|
||||
}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} {...props}>
|
||||
<BlurView
|
||||
intensity={90}
|
||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</BlurView>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
95
components/common/Dropdown.tsx
Normal file
95
components/common/Dropdown.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import React, {PropsWithChildren, useEffect, useState} from "react";
|
||||
|
||||
interface Props<T> {
|
||||
data: T[]
|
||||
placeholderText?: string,
|
||||
keyExtractor: (item: T) => string
|
||||
titleExtractor: (item: T) => string
|
||||
title: string,
|
||||
label: string,
|
||||
onSelected: (...item: T[]) => void
|
||||
multi?: boolean
|
||||
}
|
||||
|
||||
const Dropdown = <T extends unknown>({
|
||||
data,
|
||||
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 (
|
||||
<View {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<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>
|
||||
</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>
|
||||
</View>
|
||||
)
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
@@ -7,7 +7,7 @@ export function Input(props: TextInputProps) {
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
||||
className="p-4 rounded-xl bg-neutral-900"
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
|
||||
103
components/common/JellyseerrItemRouter.tsx
Normal file
103
components/common/JellyseerrItemRouter.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {useRouter, useSegments} from "expo-router";
|
||||
import React, {PropsWithChildren, useCallback, useMemo} from "react";
|
||||
import {TouchableOpacity, TouchableOpacityProps} from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
result: MovieResult | TvResult;
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: boolean;
|
||||
posterSrc: string;
|
||||
}
|
||||
|
||||
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr()
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
const autoApprove = useMemo(() => {
|
||||
return jellyseerrUser && hasPermission(
|
||||
Permission.AUTO_APPROVE,
|
||||
jellyseerrUser.permissions,
|
||||
{type: 'or'}
|
||||
)
|
||||
}, [jellyseerrApi, jellyseerrUser])
|
||||
|
||||
const request = useCallback(() =>
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: result.id,
|
||||
mediaType: result.mediaType
|
||||
}
|
||||
),
|
||||
[jellyseerrApi, result]
|
||||
)
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// @ts-ignore
|
||||
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
avoidCollisions
|
||||
alignOffset={0}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||
{canRequest && result.mediaType === MediaType.MOVIE && (
|
||||
<ContextMenu.Item
|
||||
key="item-1"
|
||||
onSelect={() => {
|
||||
if (autoApprove) {
|
||||
request()
|
||||
}
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "arrow.down.to.line",
|
||||
pointSize: 18,
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "purple",
|
||||
light: "purple",
|
||||
},
|
||||
}}
|
||||
androidIconName="download"
|
||||
/>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,24 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
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 {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
if (item.CollectionType === "livetv") {
|
||||
export const itemRouter = (
|
||||
item: BaseItemDto | BaseItemPerson,
|
||||
from: string
|
||||
) => {
|
||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
}
|
||||
|
||||
@@ -29,7 +38,7 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Person") {
|
||||
if (item.Type === "Person" || item.Type === "Actor") {
|
||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||
}
|
||||
|
||||
@@ -59,16 +68,46 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
const showActionSheet = useCallback(() => {
|
||||
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
|
||||
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
onLongPress={showActionSheet}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { TextProps } from "react-native";
|
||||
@@ -29,7 +29,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||
s += size;
|
||||
}
|
||||
}
|
||||
setSize(bytesToReadable(s));
|
||||
setSize(s.bytesToReadable());
|
||||
}, [itemIds]);
|
||||
|
||||
const sizeText = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import {
|
||||
@@ -26,6 +26,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
@@ -41,7 +42,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
successHapticFeedback();
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
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 { TouchableOpacity, View } from "react-native";
|
||||
|
||||
@@ -28,6 +28,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
@@ -43,7 +44,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
successHapticFeedback();
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
@@ -97,7 +98,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
<View className="w-28">
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
<DownloadSize items={[item]} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
119
components/home/Favorites.tsx
Normal file
119
components/home/Favorites.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
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";
|
||||
|
||||
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]
|
||||
);
|
||||
const fetchFavoriteMusicAlbum = useCallback(
|
||||
() => fetchFavoritesByType("MusicAlbum"),
|
||||
[fetchFavoritesByType]
|
||||
);
|
||||
const fetchFavoriteAudio = useCallback(
|
||||
() => fetchFavoritesByType("Audio"),
|
||||
[fetchFavoritesByType]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex flex-co gap-y-4">
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
title="Series"
|
||||
hideIfEmpty
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
title="Movies"
|
||||
hideIfEmpty
|
||||
orientation="vertical"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
title="Episodes"
|
||||
hideIfEmpty
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
title="Videos"
|
||||
hideIfEmpty
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
title="Boxsets"
|
||||
hideIfEmpty
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
title="Playlists"
|
||||
hideIfEmpty
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteMusicAlbum}
|
||||
queryKey={["home", "favorites", "musicAlbums"]}
|
||||
title="Music Albums"
|
||||
hideIfEmpty
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryFn={fetchFavoriteAudio}
|
||||
queryKey={["home", "favorites", "audio"]}
|
||||
title="Audio"
|
||||
hideIfEmpty
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ import { itemRouter, TouchableItemRouter } 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";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -84,21 +84,27 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
|
||||
const width = Dimensions.get("screen").width;
|
||||
|
||||
if (settings?.usePopularPlugin === false) return null;
|
||||
if (l1 || l2) return null;
|
||||
if (!popularItems) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col items-center" {...props}>
|
||||
<View className="flex flex-col items-center mt-2" {...props}>
|
||||
<Carousel
|
||||
autoPlay={true}
|
||||
autoPlayInterval={3000}
|
||||
loop={true}
|
||||
ref={ref}
|
||||
autoPlay={false}
|
||||
loop={true}
|
||||
snapEnabled={true}
|
||||
mode="parallax"
|
||||
modeConfig={{
|
||||
parallaxScrollingScale: 0.86,
|
||||
parallaxScrollingOffset: 100,
|
||||
}}
|
||||
width={width}
|
||||
height={204}
|
||||
data={popularItems}
|
||||
onProgressChange={progress}
|
||||
renderItem={({ item, index }) => <RenderItem item={item} />}
|
||||
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
|
||||
/>
|
||||
<Pagination.Basic
|
||||
progress={progress}
|
||||
@@ -122,6 +128,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
const screenWidth = Dimensions.get("screen").width;
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const uri = useMemo(() => {
|
||||
if (!api) return null;
|
||||
@@ -147,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const handleRoute = useCallback(() => {
|
||||
if (!from) return;
|
||||
const url = itemRouter(item, from);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
lightHapticFeedback();
|
||||
// @ts-ignore
|
||||
if (url) router.push(url);
|
||||
}, [item, from]);
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Props extends ViewProps {
|
||||
disabled?: boolean;
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
}
|
||||
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -26,10 +27,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
disabled = false,
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
...props
|
||||
}) => {
|
||||
// console.log(queryKey);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn,
|
||||
@@ -41,8 +41,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
|
||||
if (disabled || !title) return null;
|
||||
|
||||
if (hideIfEmpty === true && data?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View {...props} className="">
|
||||
<View {...props}>
|
||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||
{title}
|
||||
</Text>
|
||||
@@ -82,15 +84,13 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
) : (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{data?.map((item, index) => (
|
||||
{data?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={index}
|
||||
className={`
|
||||
mr-2
|
||||
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
key={item.Id}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
|
||||
39
components/jellyseerr/Cast.tsx
Normal file
39
components/jellyseerr/Cast.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import React from "react";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
||||
|
||||
const CastSlide: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, ...props }) => {
|
||||
return (
|
||||
details?.credits?.cast &&
|
||||
details?.credits?.cast?.length > 0 && (
|
||||
<View {...props}>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Cast</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={details?.credits.cast}
|
||||
ItemSeparatorComponent={() => <View className="w-2" />}
|
||||
estimatedItemSize={15}
|
||||
keyExtractor={(item) => item?.id?.toString()}
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
renderItem={({ item }) => (
|
||||
<PersonPoster
|
||||
id={item.id.toString()}
|
||||
posterPath={item.profilePath}
|
||||
name={item.name}
|
||||
subName={item.character}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default CastSlide;
|
||||
218
components/jellyseerr/DetailFacts.tsx
Normal file
218
components/jellyseerr/DetailFacts.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { uniqBy } from "lodash";
|
||||
import { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import CountryFlag from "react-native-country-flag";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
|
||||
interface Release {
|
||||
certification: string;
|
||||
iso_639_1?: string;
|
||||
note?: string;
|
||||
release_date: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
const Facts: React.FC<
|
||||
{ title: string; facts?: string[] | React.ReactNode[] } & ViewProps
|
||||
> = ({ title, facts, ...props }) =>
|
||||
facts &&
|
||||
facts?.length > 0 && (
|
||||
<View className="flex flex-col justify-between py-2" {...props}>
|
||||
<Text className="font-bold text-start">{title}</Text>
|
||||
|
||||
<View className="flex flex-col items-end">
|
||||
{facts.map((f, idx) =>
|
||||
typeof f === "string" ? <Text key={idx}>{f}</Text> : f
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
||||
title,
|
||||
fact,
|
||||
...props
|
||||
}) => fact && <Facts title={title} facts={[fact]} {...props} />;
|
||||
|
||||
const DetailFacts: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, className, ...props }) => {
|
||||
const { jellyseerrUser } = useJellyseerr();
|
||||
|
||||
const locale = useMemo(() => {
|
||||
return jellyseerrUser?.settings?.locale || "en";
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const region = useMemo(
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser]
|
||||
);
|
||||
|
||||
const releases = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.releases?.results.find(
|
||||
(r: TmdbRelease) => r.iso_3166_1 === region
|
||||
)?.release_dates as TmdbRelease["release_dates"],
|
||||
[details]
|
||||
);
|
||||
|
||||
// Release date types:
|
||||
// 1. Premiere
|
||||
// 2. Theatrical (limited)
|
||||
// 3. Theatrical
|
||||
// 4. Digital
|
||||
// 5. Physical
|
||||
// 6. TV
|
||||
const filteredReleases = useMemo(
|
||||
() =>
|
||||
uniqBy(
|
||||
releases?.filter((r: Release) => r.type > 2 && r.type < 6),
|
||||
"type"
|
||||
),
|
||||
[releases]
|
||||
);
|
||||
|
||||
const firstAirDate = useMemo(() => {
|
||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||
if (firstAirDate) {
|
||||
return new Date(firstAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
);
|
||||
}
|
||||
}, [details]);
|
||||
|
||||
const nextAirDate = useMemo(() => {
|
||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
|
||||
if (nextAirDate && firstAirDate !== nextAirDate) {
|
||||
return new Date(nextAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
);
|
||||
}
|
||||
}, [details]);
|
||||
|
||||
const revenue = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.revenue?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" }
|
||||
),
|
||||
[details]
|
||||
);
|
||||
|
||||
const budget = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.budget?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" }
|
||||
),
|
||||
[details]
|
||||
);
|
||||
|
||||
const streamingProviders = useMemo(
|
||||
() =>
|
||||
details?.watchProviders?.find(
|
||||
(provider) => provider.iso_3166_1 === region
|
||||
)?.flatrate,
|
||||
[details]
|
||||
);
|
||||
|
||||
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
||||
|
||||
const spokenLanguage = useMemo(
|
||||
() =>
|
||||
details?.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === details.originalLanguage
|
||||
)?.name,
|
||||
[details]
|
||||
);
|
||||
|
||||
return (
|
||||
details && (
|
||||
<View className="p-4">
|
||||
<Text className="text-lg font-bold">Details</Text>
|
||||
<View
|
||||
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
||||
{...props}
|
||||
>
|
||||
<Fact title="Status" fact={details?.status} />
|
||||
<Fact
|
||||
title="Original Title"
|
||||
fact={(details as TvDetails)?.originalName}
|
||||
/>
|
||||
{details.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
) && <Fact title="Series Type" fact="Anime" />}
|
||||
<Facts
|
||||
title="Release Dates"
|
||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||
<View key={idx} className="flex flex-row space-x-2 items-center">
|
||||
{r.type === 3 ? (
|
||||
// Theatrical
|
||||
<Ionicons name="ticket" size={16} color="white" />
|
||||
) : r.type === 4 ? (
|
||||
// Digital
|
||||
<Ionicons name="cloud" size={16} color="white" />
|
||||
) : (
|
||||
// Physical
|
||||
<MaterialCommunityIcons
|
||||
name="record-circle-outline"
|
||||
size={16}
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
<Text>
|
||||
{new Date(r.release_date).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
/>
|
||||
<Fact title="First Air Date" fact={firstAirDate} />
|
||||
<Fact title="Next Air Date" fact={nextAirDate} />
|
||||
<Fact title="Revenue" fact={revenue} />
|
||||
<Fact title="Budget" fact={budget} />
|
||||
<Fact title="Original Language" fact={spokenLanguage} />
|
||||
<Facts
|
||||
title="Production Country"
|
||||
facts={details?.productionCountries?.map((n, idx) => (
|
||||
<View key={idx} className="flex flex-row items-center space-x-2">
|
||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||
<Text>{n.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
/>
|
||||
<Facts
|
||||
title="Studios"
|
||||
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
||||
(n) => n.name
|
||||
)}
|
||||
/>
|
||||
<Facts title="Network" facts={networks?.map((n) => n.name)} />
|
||||
<Facts
|
||||
title="Currently Streaming on"
|
||||
facts={streamingProviders?.map((s) => s.name)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailFacts;
|
||||
159
components/jellyseerr/JellyseerrIndexPage.tsx
Normal file
159
components/jellyseerr/JellyseerrIndexPage.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import Discover from "@/components/jellyseerr/discover/Discover";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import {
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
||||
import React, { useMemo } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import {
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "../common/Text";
|
||||
import JellyseerrPoster from "../posters/JellyseerrPoster";
|
||||
import { LoadingSkeleton } from "../search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "../search/SearchItemWrapper";
|
||||
import PersonPoster from "./PersonPoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const {
|
||||
data: jellyseerrDiscoverSettings,
|
||||
isFetching: f1,
|
||||
isLoading: l1,
|
||||
} = useReactNavigationQuery({
|
||||
queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
|
||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||
enabled: !!jellyseerrApi && searchQuery.length == 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: jellyseerrResults,
|
||||
isFetching: f2,
|
||||
isLoading: l2,
|
||||
} = useReactNavigationQuery({
|
||||
queryKey: ["search", "jellyseerr", "results", searchQuery],
|
||||
queryFn: async () => {
|
||||
const response = await jellyseerrApi?.search({
|
||||
query: new URLSearchParams(searchQuery).toString(),
|
||||
page: 1,
|
||||
language: "en",
|
||||
});
|
||||
return response?.results;
|
||||
},
|
||||
enabled: !!jellyseerrApi && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
useAnimatedReaction(
|
||||
() => f1 || f2 || l1 || l2,
|
||||
(isLoading) => {
|
||||
if (isLoading) {
|
||||
opacity.value = withTiming(1, { duration: 200 });
|
||||
} else {
|
||||
opacity.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const jellyseerrMovieResults = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.MOVIE
|
||||
) as MovieResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const jellyseerrTvResults = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.TV
|
||||
) as TvResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const jellyseerrPersonResults = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === "person"
|
||||
) as PersonResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
if (!searchQuery.length)
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
<Discover sliders={jellyseerrDiscoverSettings} />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
|
||||
|
||||
{!jellyseerrMovieResults?.length &&
|
||||
!jellyseerrTvResults?.length &&
|
||||
!jellyseerrPersonResults?.length &&
|
||||
!f1 &&
|
||||
!f2 &&
|
||||
!l1 &&
|
||||
!l2 && (
|
||||
<View>
|
||||
<Text className="text-center text-lg font-bold mt-4">
|
||||
No results found for
|
||||
</Text>
|
||||
<Text className="text-xs text-purple-600 text-center">
|
||||
"{searchQuery}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header="Request Movies"
|
||||
items={jellyseerrMovieResults}
|
||||
renderItem={(item: MovieResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header="Request Series"
|
||||
items={jellyseerrTvResults}
|
||||
renderItem={(item: TvResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header="Actors"
|
||||
items={jellyseerrPersonResults}
|
||||
renderItem={(item: PersonResult) => (
|
||||
<PersonPoster
|
||||
className="mr-2"
|
||||
key={item.id}
|
||||
id={item.id.toString()}
|
||||
name={item.name}
|
||||
posterPath={item.profilePath}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
37
components/jellyseerr/JellyseerrMediaIcon.tsx
Normal file
37
components/jellyseerr/JellyseerrMediaIcon.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import {useMemo} from "react";
|
||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {Feather, MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {View, ViewProps} from "react-native";
|
||||
|
||||
const JellyseerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
|
||||
mediaType,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const style = useMemo(
|
||||
() => mediaType === MediaType.MOVIE
|
||||
? 'bg-blue-600/90 border-blue-400/40'
|
||||
: 'bg-purple-600/90 border-purple-400/40',
|
||||
[mediaType]
|
||||
);
|
||||
return (
|
||||
mediaType &&
|
||||
<View className={`${className} border ${style} rounded-full p-1`} {...props}>
|
||||
{mediaType === MediaType.MOVIE ? (
|
||||
<MaterialCommunityIcons
|
||||
name="movie-open"
|
||||
size={16}
|
||||
color="white"
|
||||
/>
|
||||
) : (
|
||||
<Feather
|
||||
size={16}
|
||||
name="tv"
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default JellyseerrMediaIcon;
|
||||
71
components/jellyseerr/JellyseerrStatusIcon.tsx
Normal file
71
components/jellyseerr/JellyseerrStatusIcon.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||
|
||||
interface Props {
|
||||
mediaStatus?: MediaStatus;
|
||||
showRequestIcon: boolean;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||
mediaStatus,
|
||||
showRequestIcon,
|
||||
onPress,
|
||||
...props
|
||||
}) => {
|
||||
const [badgeIcon, setBadgeIcon] = useState<keyof typeof MaterialCommunityIcons.glyphMap>();
|
||||
const [badgeStyle, setBadgeStyle] = useState<string>();
|
||||
|
||||
// Match similar to what Jellyseerr is currently using
|
||||
// https://github.com/Fallenbagel/jellyseerr/blob/8a097d5195749c8d1dca9b473b8afa96a50e2fe2/src/components/Common/StatusBadgeMini/index.tsx#L33C1-L62C4
|
||||
useEffect(() => {
|
||||
switch (mediaStatus) {
|
||||
case MediaStatus.PROCESSING:
|
||||
setBadgeStyle('bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100');
|
||||
setBadgeIcon('clock');
|
||||
break;
|
||||
case MediaStatus.AVAILABLE:
|
||||
setBadgeStyle('bg-purple-500 border-green-400 ring-green-400 text-green-100');
|
||||
setBadgeIcon('check')
|
||||
break;
|
||||
case MediaStatus.PENDING:
|
||||
setBadgeStyle('bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100');
|
||||
setBadgeIcon('bell')
|
||||
break;
|
||||
case MediaStatus.BLACKLISTED:
|
||||
setBadgeStyle('bg-red-500 border-white-400 ring-white-400 text-white');
|
||||
setBadgeIcon('eye-off')
|
||||
break;
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
setBadgeStyle('bg-green-500 border-green-400 ring-green-400 text-green-100');
|
||||
setBadgeIcon("minus");
|
||||
break;
|
||||
default:
|
||||
if (showRequestIcon) {
|
||||
setBadgeStyle('bg-green-600');
|
||||
setBadgeIcon("plus")
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon])
|
||||
|
||||
return (
|
||||
badgeIcon &&
|
||||
<TouchableOpacity onPress={onPress} disabled={onPress == undefined}>
|
||||
<View
|
||||
className={`${badgeStyle ?? 'bg-purple-600'} rounded-full h-6 w-6 flex items-center justify-center ${props.className}`}
|
||||
{...props}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={badgeIcon}
|
||||
size={18}
|
||||
color="white"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
export default JellyseerrStatusIcon;
|
||||
160
components/jellyseerr/ParallaxSlideShow.tsx
Normal file
160
components/jellyseerr/ParallaxSlideShow.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {Dimensions, View, ViewProps} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Animated } from "react-native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import {useFocusEffect} from "expo-router";
|
||||
|
||||
const ANIMATION_ENTER = 250;
|
||||
const ANIMATION_EXIT = 250;
|
||||
const BACKDROP_DURATION = 5000;
|
||||
|
||||
type Render = React.ComponentType<any>
|
||||
| React.ReactElement
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
interface Props<T> {
|
||||
data: T[]
|
||||
images: string[];
|
||||
logo?: React.ReactElement;
|
||||
HeaderContent?: () => React.ReactElement;
|
||||
MainContent?: () => React.ReactElement;
|
||||
listHeader: string;
|
||||
renderItem: (item: T, index: number) => Render;
|
||||
keyExtractor: (item: T) => string;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
}
|
||||
|
||||
const ParallaxSlideShow = <T extends unknown>({
|
||||
data,
|
||||
images,
|
||||
logo,
|
||||
HeaderContent,
|
||||
MainContent,
|
||||
listHeader,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
...props
|
||||
}: PropsWithChildren<Props<T> & ViewProps>
|
||||
) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const enterAnimation = useCallback(
|
||||
() =>
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: ANIMATION_ENTER,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
[fadeAnim]
|
||||
);
|
||||
|
||||
const exitAnimation = useCallback(
|
||||
() =>
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: ANIMATION_EXIT,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
[fadeAnim]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (images?.length) {
|
||||
enterAnimation().start();
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
Animated.sequence([
|
||||
enterAnimation(),
|
||||
exitAnimation()
|
||||
]).start(() => {
|
||||
fadeAnim.setValue(0);
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
|
||||
})
|
||||
}, BACKDROP_DURATION);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId)
|
||||
};
|
||||
}
|
||||
}, [fadeAnim, images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className="flex-1 opacity-100"
|
||||
headerHeight={300}
|
||||
onEndReached={onEndReached}
|
||||
headerImage={
|
||||
<Animated.Image
|
||||
key={images?.[currentIndex]}
|
||||
id={images?.[currentIndex]}
|
||||
source={{
|
||||
uri: images?.[currentIndex],
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: fadeAnim,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={logo}
|
||||
>
|
||||
<View className="flex flex-col space-y-4 px-4">
|
||||
<View className="flex flex-row justify-between w-full">
|
||||
<View className="flex flex-col w-full">
|
||||
{HeaderContent && HeaderContent()}
|
||||
</View>
|
||||
</View>
|
||||
{MainContent && MainContent()}
|
||||
<View>
|
||||
<FlashList
|
||||
data={data}
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">
|
||||
No results
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={
|
||||
<Text className="text-lg font-bold my-2">{listHeader}</Text>
|
||||
}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
//@ts-ignore
|
||||
renderItem={({ item, index}) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
estimatedItemSize={214}
|
||||
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default ParallaxSlideShow;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user