Compare commits
1 Commits
retardgerm
...
fix/save-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c6a0f69ae |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone15Pro]
|
||||
- OS: [e.g. iOS18]
|
||||
- Version [e.g. 0.3.1]
|
||||
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- ["❌ bug"]
|
||||
projects:
|
||||
- ["fredrikburmester/5"]
|
||||
assignees:
|
||||
- fredrikburmester
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: device
|
||||
attributes:
|
||||
label: Which device and operating system are you using?
|
||||
description: e.g. iPhone 15, iOS 18.1.1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.23.0
|
||||
- 0.22.0
|
||||
- 0.21.0
|
||||
- older
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: If applicable, please add screenshots to help explain your problem.
|
||||
You can drag and drop images here or paste them directly into the comment box.
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: '✨ enhancement'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
7
.gitignore
vendored
@@ -9,7 +9,6 @@ npm-debug.*
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
modules/vlc-player/android/build
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -22,17 +21,11 @@ build-*
|
||||
*.mp4
|
||||
build-*
|
||||
Streamyfin.app
|
||||
package-lock.json
|
||||
|
||||
/ios
|
||||
/android
|
||||
|
||||
modules/player/android
|
||||
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
*.apk
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
|
||||
.vscode/
|
||||
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule "utils/jellyseerr"]
|
||||
path = utils/jellyseerr
|
||||
url = https://github.com/herrrta/jellyseerr
|
||||
branch = models
|
||||
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
3
.vscode/settings.json
vendored
@@ -8,8 +8,5 @@
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
}
|
||||
}
|
||||
|
||||
92
README.md
@@ -1,24 +1,22 @@
|
||||
# 📺 Streamyfin
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||
<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" />
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 5px">
|
||||
<img width=100 src="./assets/images/screenshots/1.jpg" />
|
||||
<img width=100 src="./assets/images/screenshots/3.jpg" />
|
||||
<img width=100 src="./assets/images/screenshots/4.jpg" />
|
||||
<img width=100 src="./assets/images/screenshots/5.jpg" />
|
||||
<img width=100 src="./assets/images/screenshots/7.jpg" />
|
||||
</div>
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- 🚀 **Skp intro / credits support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
||||
- 📺 **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
|
||||
|
||||
@@ -26,35 +24,20 @@ Streamyfin includes some exciting experimental features like media downloading a
|
||||
|
||||
### Downloading
|
||||
|
||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
### Chromecast
|
||||
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||
|
||||
## Plugins
|
||||
|
||||
In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
|
||||
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
### Collection rows
|
||||
|
||||
Jellyfin collections can be shown as rows or carousel on the home screen.
|
||||
The following tags can be added to a collection to provide this functionality.
|
||||
The following tags can be added to an collection to provide this functionality.
|
||||
|
||||
Available tags:
|
||||
Avaiable tags:
|
||||
- sf_promoted: Wil make the collection an row on home
|
||||
- sf_carousel: Wil make the collection an carousel on home.
|
||||
|
||||
- sf_promoted: will make the collection a row at home
|
||||
- sf_carousel: will make the collection a carousel on home.
|
||||
|
||||
A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc.
|
||||
A plugin exists to create collections based on external sources like mdblist. This makes managing collections like trending, most watched etc an automatic process.
|
||||
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
|
||||
|
||||
### Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
## Roadmap for V1
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||
@@ -62,7 +45,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
|
||||
## Get it now
|
||||
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||
</div>
|
||||
|
||||
@@ -70,9 +53,11 @@ Or download the APKs [here on GitHub](https://github.com/fredrikburmester/stream
|
||||
|
||||
### Beta testing
|
||||
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||
Get the latest updates by using the TestFlight version of the app.
|
||||
|
||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||
<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>
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -87,10 +72,30 @@ 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 && 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.
|
||||
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>
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
@@ -108,13 +113,17 @@ 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/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
|
||||
|
||||
If you have questions or need support, feel free to reach out:
|
||||
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||
|
||||
## Support
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
## 📝 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.
|
||||
@@ -125,9 +134,4 @@ 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)
|
||||
|
||||
43
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.23.0",
|
||||
"version": "0.7.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -10,7 +10,7 @@
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#2E2E2E"
|
||||
"backgroundColor": "#29164B"
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
@@ -19,34 +19,32 @@
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||
"UIBackgroundModes": ["audio", "fetch"],
|
||||
"UIBackgroundModes": ["audio"],
|
||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"UISupportsTrueScreenSizeOnMac": true,
|
||||
"UIFileSharingEnabled": true,
|
||||
"LSSupportsOpeningDocumentsInPlace": true
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 49,
|
||||
"versionCode": 19,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
"android.permission.WRITE_SETTINGS"
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
@@ -74,15 +72,9 @@
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "15.6",
|
||||
"useFrameworks": "static"
|
||||
"deploymentTarget": "14.0"
|
||||
},
|
||||
"android": {
|
||||
"android": {
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 34,
|
||||
"buildToolsVersion": "34.0.0"
|
||||
},
|
||||
"minSdkVersion": 24,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
@@ -104,14 +96,7 @@
|
||||
{
|
||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||
}
|
||||
],
|
||||
"expo-asset",
|
||||
[
|
||||
"react-native-edge-to-edge",
|
||||
{ "android": { "parentTheme": "Material3" } }
|
||||
],
|
||||
["react-native-bottom-tabs"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"]
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import {FlatList, TouchableOpacity, View} from "react-native";
|
||||
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
||||
import React, {useCallback, useEffect, useState} from "react";
|
||||
import {useAtom} from "jotai/index";
|
||||
import {apiAtom} from "@/providers/JellyfinProvider";
|
||||
import {ListItem} from "@/components/ListItem";
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import {Text} from "@/components/common/Text";
|
||||
|
||||
export interface MenuLink {
|
||||
name: string,
|
||||
url: string,
|
||||
icon: string
|
||||
}
|
||||
|
||||
export default function menuLinks() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets()
|
||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
|
||||
|
||||
const getMenuLinks = useCallback(async () => {
|
||||
try {
|
||||
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
|
||||
const config = response?.data;
|
||||
|
||||
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||
console.error("Menu links not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setMenuLinks(config?.menuLinks as MenuLink[])
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve config:", error);
|
||||
}
|
||||
},
|
||||
[api]
|
||||
)
|
||||
|
||||
useEffect(() => { getMenuLinks() }, []);
|
||||
return (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 10,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
data={menuLinks}
|
||||
renderItem={({item}) => (
|
||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
|
||||
<ListItem
|
||||
title={item.name}
|
||||
iconAfter={<Ionicons name="link" size={24} color="white"/>}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Home",
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
>
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads/index"
|
||||
options={{
|
||||
title: "Downloads",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads/[seriesId]"
|
||||
options={{
|
||||
title: "TV-Series",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const local = useLocalSearchParams();
|
||||
const { seriesId, episodeSeasonIndex } = local as {
|
||||
seriesId: string;
|
||||
episodeSeasonIndex: number | string | undefined;
|
||||
};
|
||||
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||
{}
|
||||
);
|
||||
const { downloadedFiles, deleteItems } = useDownload();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
downloadedFiles
|
||||
?.filter((f) => f.item.SeriesId == seriesId)
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
||||
) || []
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const seasonIndex =
|
||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||
episodeSeasonIndex ||
|
||||
"";
|
||||
|
||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||
const seasons: Record<string, BaseItemDto[]> = {};
|
||||
|
||||
series?.forEach((episode) => {
|
||||
if (!seasons[episode.item.ParentIndexNumber!]) {
|
||||
seasons[episode.item.ParentIndexNumber!] = [];
|
||||
}
|
||||
|
||||
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
||||
});
|
||||
return (
|
||||
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
||||
[]
|
||||
);
|
||||
}, [series, seasonIndex]);
|
||||
|
||||
const initialSeasonIndex = useMemo(
|
||||
() =>
|
||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||
series?.[0]?.item?.ParentIndexNumber,
|
||||
[groupBySeason]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (series.length > 0) {
|
||||
navigation.setOptions({
|
||||
title: series[0].item.SeriesName,
|
||||
});
|
||||
} else {
|
||||
storage.delete(seriesId);
|
||||
router.back();
|
||||
}
|
||||
}, [series]);
|
||||
|
||||
const deleteSeries = useCallback(() => {
|
||||
Alert.alert(
|
||||
"Delete season",
|
||||
"Are you sure you want to delete the entire season?",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
onPress: () => deleteItems(groupBySeason),
|
||||
style: "destructive",
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [groupBySeason]);
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
{series.length > 0 && (
|
||||
<View className="flex flex-row items-center justify-start my-2 px-4">
|
||||
<SeasonDropdown
|
||||
item={series[0].item}
|
||||
seasons={series.map((s) => s.item)}
|
||||
state={seasonIndexState}
|
||||
initialSeasonIndex={initialSeasonIndex!}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
||||
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
||||
</View>
|
||||
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
||||
<TouchableOpacity onPress={deleteSeries}>
|
||||
<Ionicons name="trash" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ScrollView key={seasonIndex} className="px-4">
|
||||
{groupBySeason.map((episode, index) => (
|
||||
<EpisodeCard key={index} item={episode} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {useNavigation, useRouter} from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {useEffect, useMemo, useRef} from "react";
|
||||
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
||||
import {toast} from "sonner-native";
|
||||
import {writeToLog} from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
||||
const router = useRouter();
|
||||
const [settings] = useSettings();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||
} catch {
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const groupedBySeries = useMemo(() => {
|
||||
try {
|
||||
const episodes = downloadedFiles?.filter(
|
||||
(f) => f.item.Type === "Episode"
|
||||
);
|
||||
const series: { [key: string]: DownloadedItem[] } = {};
|
||||
episodes?.forEach((e) => {
|
||||
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
||||
series[e.item.SeriesName!].push(e);
|
||||
});
|
||||
return Object.values(series);
|
||||
} catch {
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={bottomSheetModalRef.current?.present}
|
||||
>
|
||||
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const deleteMovies = () => deleteFileByType("Movie")
|
||||
.then(() => toast.success("Deleted all movies successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all movies");
|
||||
});
|
||||
const deleteShows = () => deleteFileByType("Episode")
|
||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all TV-Series");
|
||||
});
|
||||
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className="py-4">
|
||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||
{settings?.downloadMethod === "remux" && (
|
||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">Queue</Text>
|
||||
<Text className="text-xs opacity-70 text-red-600">
|
||||
Queue and downloads will be lost on app restart
|
||||
</Text>
|
||||
<View className="flex flex-col space-y-2 mt-2">
|
||||
{queue.map((q, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
key={index}
|
||||
>
|
||||
<View>
|
||||
<Text className="font-semibold">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeProcess(q.id);
|
||||
setQueue((prev) => {
|
||||
if (!prev) return [];
|
||||
return [...prev.filter((i) => i.id !== q.id)];
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red"/>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className="opacity-50">No items in queue</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ActiveDownloads/>
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||
<Text className="text-lg font-bold">Movies</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{movies?.map((item) => (
|
||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||
<MovieCard item={item.item}/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||
<Text className="text-lg font-bold">TV-Series</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className="flex px-4">
|
||||
<Text className="opacity-50">No downloaded items</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="p-4 space-y-4 mb-4">
|
||||
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
|
||||
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
|
||||
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function migration_20241124() {
|
||||
const router = useRouter();
|
||||
const { deleteAllFiles } = useDownload();
|
||||
Alert.alert(
|
||||
"New app version requires re-download",
|
||||
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||
[
|
||||
{
|
||||
text: "Back",
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => await deleteAllFiles(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type ScrollingCollectionListSection = {
|
||||
type: "ScrollingCollectionList";
|
||||
title?: string;
|
||||
queryKey: (string | undefined | null)[];
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
};
|
||||
|
||||
type MediaListSection = {
|
||||
type: "MediaListSection";
|
||||
queryKey: (string | undefined)[];
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||
|
||||
export default function index() {
|
||||
const router = useRouter();
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settings, _] = useSettings();
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||
|
||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Feather
|
||||
name="download"
|
||||
color={hasDownloads ? Colors.primary : "white"}
|
||||
size={22}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [downloadedFiles, navigation, router]);
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
setLoadingRetry(true);
|
||||
const state = await NetInfo.fetch();
|
||||
setIsConnected(state.isConnected);
|
||||
setLoadingRetry(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
if (state.isConnected == false || state.isInternetReachable === false)
|
||||
setIsConnected(false);
|
||||
else setIsConnected(true);
|
||||
});
|
||||
|
||||
NetInfo.fetch().then((state) => {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
cleanCacheDirectory()
|
||||
.then(r => console.log("Cache directory cleaned"))
|
||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: userViews,
|
||||
isError: e1,
|
||||
isLoading: l1,
|
||||
} = useQuery({
|
||||
queryKey: ["home", "userViews", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: mediaListCollections,
|
||||
isError: e2,
|
||||
isLoading: l2,
|
||||
} = useQuery({
|
||||
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
tags: ["sf_promoted"],
|
||||
recursive: true,
|
||||
fields: ["Tags"],
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const collections = useMemo(() => {
|
||||
const allow = ["movies", "tvshows"];
|
||||
return (
|
||||
userViews?.filter(
|
||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
||||
) || []
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const createCollectionConfig = useCallback(
|
||||
(
|
||||
title: string,
|
||||
queryKey: string[],
|
||||
includeItemTypes: BaseItemKind[],
|
||||
parentId: string | undefined
|
||||
): ScrollingCollectionListSection => ({
|
||||
title,
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
return (
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 20,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || []
|
||||
);
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
}),
|
||||
[api, user?.Id]
|
||||
);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||
const title = "Recently Added in " + c.Name;
|
||||
const queryKey = [
|
||||
"home",
|
||||
"recentlyAddedIn" + c.CollectionType,
|
||||
user?.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
title || "",
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id
|
||||
);
|
||||
});
|
||||
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: "Continue Watching",
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
{
|
||||
title: "Next Up",
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
...latestMediaViews,
|
||||
...(mediaListCollections?.map(
|
||||
(ml) =>
|
||||
({
|
||||
title: ml.Name,
|
||||
queryKey: ["home", "mediaList", ml.Id!],
|
||||
queryFn: async () => ml,
|
||||
type: "MediaListSection",
|
||||
orientation: "vertical",
|
||||
} as Section)
|
||||
) || []),
|
||||
{
|
||||
title: "Suggested Movies",
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
},
|
||||
{
|
||||
title: "Suggested Episodes",
|
||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const suggestions = await getSuggestions(api, user.Id);
|
||||
const nextUpPromises = suggestions.map((series) =>
|
||||
getNextUp(api, user.Id, series.Id)
|
||||
);
|
||||
const nextUpResults = await Promise.all(nextUpPromises);
|
||||
|
||||
return nextUpResults.filter((item) => item !== null) || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, mediaListCollections]);
|
||||
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
No worries, you can still watch{"\n"}downloaded content.
|
||||
</Text>
|
||||
<View className="mt-4">
|
||||
<Button
|
||||
color="purple"
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify="center"
|
||||
iconRight={
|
||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||
}
|
||||
>
|
||||
Go to downloads
|
||||
</Button>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
checkConnection();
|
||||
}}
|
||||
justify="center"
|
||||
className="mt-2"
|
||||
iconRight={
|
||||
loadingRetry ? null : (
|
||||
<Ionicons name="refresh" size={20} color="white" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{loadingRetry ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
"Retry"
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (e1 || e2)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
Something went wrong.{"\n"}Please log out and in again.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (l1 || l2)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-4">
|
||||
<LargeMovieCarousel />
|
||||
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
/>
|
||||
);
|
||||
} else if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// Function to get suggestions
|
||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||
if (!userId) return [];
|
||||
const response = await getSuggestionsApi(api).getSuggestions({
|
||||
userId,
|
||||
limit: 10,
|
||||
mediaType: ["Unknown"],
|
||||
type: ["Series"],
|
||||
});
|
||||
return response.data.Items ?? [];
|
||||
}
|
||||
|
||||
// Function to get the next up TV show for a series
|
||||
async function getNextUp(
|
||||
api: Api,
|
||||
userId: string | undefined,
|
||||
seriesId: string | undefined
|
||||
) {
|
||||
if (!userId || !seriesId) return null;
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId,
|
||||
seriesId,
|
||||
limit: 1,
|
||||
});
|
||||
return response.data.Items?.[0] ?? null;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/ListItem";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import {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 FileSystem from "expo-file-system";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import * as Progress from "react-native-progress";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { logs } = useLog();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
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 onClearLogsClicked = async () => {
|
||||
clearLogs();
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
||||
Authorize
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<SettingToggles />
|
||||
|
||||
<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: {size.app.bytesToReadable()}</Text>}
|
||||
<Progress.Bar
|
||||
className="bg-gray-100/10"
|
||||
indeterminate={appSizeLoading}
|
||||
color="#9333ea"
|
||||
width={null}
|
||||
height={10}
|
||||
borderRadius={6}
|
||||
borderWidth={0}
|
||||
progress={size?.used}
|
||||
/>
|
||||
{size && (
|
||||
<Text>
|
||||
Available: {size.remaining?.bytesToReadable()}, Total:{" "}
|
||||
{size.total?.bytesToReadable()}
|
||||
</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>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { actorId } = local as { actorId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", actorId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: actorId,
|
||||
}),
|
||||
enabled: !!actorId && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !user?.Id) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
personIds: [actorId],
|
||||
startIndex: pageParam,
|
||||
limit: 16,
|
||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
recursive: true,
|
||||
fields: [
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
],
|
||||
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||
collapseBoxSetItems: false,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
[api, user?.Id, actorId]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item?.Id || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col space-y-4 my-4">
|
||||
<View className="px-4 mb-4">
|
||||
<MoviesTitleHeader item={item} className="mb-4" />
|
||||
<OverviewText text={item.Overview} />
|
||||
</View>
|
||||
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
Appeared In
|
||||
</Text>
|
||||
<InfiniteHorizontalScroll
|
||||
height={247}
|
||||
renderItem={(i, idx) => (
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
item={i}
|
||||
className={`flex flex-col
|
||||
${"w-28"}
|
||||
`}
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={i} />
|
||||
<ItemCardText item={i} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["actor", "movies", actorId]}
|
||||
/>
|
||||
<View className="h-12"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -1,415 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
SortByOption,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
SortOrderOption,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
ItemSortBy,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: collectionId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
const data = response.data;
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!collectionId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: collection?.Name || "" });
|
||||
setSortOrder([SortOrderOption.Ascending]);
|
||||
|
||||
if (!collection) return;
|
||||
|
||||
// Convert the DisplayOrder to SortByOption
|
||||
const displayOrder = collection.DisplayOrder as ItemSortBy;
|
||||
const sortByOption = displayOrder
|
||||
? SortByOption[displayOrder as keyof typeof SortByOption] ||
|
||||
SortByOption.PremiereDate
|
||||
: SortByOption.PremiereDate;
|
||||
|
||||
setSortBy([sortByOption]);
|
||||
}, [navigation, collection]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !collection) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 18,
|
||||
startIndex: pageParam,
|
||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||
sortBy: [sortBy[0]],
|
||||
sortOrder: [sortOrder[0]],
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"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;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
index % 3 === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % 3 === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
{/* <MoviePoster item={item} /> */}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className="">
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title="Years"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title="Sort By"
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title="Sort Order"
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
collectionId,
|
||||
api,
|
||||
user?.Id,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
selectedYears,
|
||||
setSelectedYears,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
]
|
||||
);
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
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>
|
||||
}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
|
||||
const { data: item, isError } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !id) return;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const fadeIn = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
fadeOut(() => {});
|
||||
} else {
|
||||
fadeIn(() => {});
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
||||
<Text>Could not load item</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex flex-1 relative">
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
style={[animatedStyle]}
|
||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,299 +0,0 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { Image } from "expo-image";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { Button } from "@/components/Button";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest: canRequestString,
|
||||
posterSrc,
|
||||
...result
|
||||
} = params as unknown as {
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: string;
|
||||
posterSrc: string;
|
||||
} & Partial<MovieResult | TvResult>;
|
||||
|
||||
const canRequest = canRequestString === "true";
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const {
|
||||
data: details,
|
||||
isFetching,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!!)
|
||||
: jellyseerrApi?.tvDetails(result.id!!);
|
||||
},
|
||||
});
|
||||
|
||||
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(
|
||||
() =>
|
||||
requestMedia(mediaTitle, {
|
||||
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),
|
||||
}),
|
||||
[details, result, requestMedia]
|
||||
);
|
||||
|
||||
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: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
<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>
|
||||
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder="(optional) Describe the issue..."
|
||||
value={issueMessage}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="none"
|
||||
maxLength={254}
|
||||
onChangeText={setIssueMessage}
|
||||
/>
|
||||
</View>
|
||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||
Submit
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,49 +0,0 @@
|
||||
import type {
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
||||
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
||||
import { Stack, withLayoutContext } from "expo-router";
|
||||
import React from "react";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
export const Tab = withLayoutContext<
|
||||
MaterialTopTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
MaterialTopTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
const Layout = () => {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Live TV" }} />
|
||||
<Tab
|
||||
initialRouteName="programs"
|
||||
keyboardDismissMode="none"
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
tabBarItemStyle: {
|
||||
width: 100,
|
||||
},
|
||||
tabBarStyle: { backgroundColor: "black" },
|
||||
animationEnabled: true,
|
||||
lazy: true,
|
||||
swipeEnabled: true,
|
||||
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||
tabBarScrollEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name="programs" />
|
||||
<Tab.Screen name="guide" />
|
||||
<Tab.Screen name="channels" />
|
||||
<Tab.Screen name="recordings" />
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels"],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: 0,
|
||||
limit: 500,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="flex flex-1">
|
||||
<FlashList
|
||||
data={channels?.Items}
|
||||
estimatedItemSize={76}
|
||||
renderItem={({ item }) => (
|
||||
<View className="flex flex-row items-center px-4 mb-2">
|
||||
<View className="w-22 mr-4 rounded-lg overflow-hidden">
|
||||
<ItemImage
|
||||
style={{
|
||||
aspectRatio: "1/1",
|
||||
width: 60,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
item={item}
|
||||
/>
|
||||
</View>
|
||||
<Text className="font-bold">{item.Name}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [date, setDate] = useState<Date>(new Date());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { data: guideInfo } = useQuery({
|
||||
queryKey: ["livetv", "guideInfo"],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels", currentPage],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: programs } = useQuery({
|
||||
queryKey: ["livetv", "programs", date, currentPage],
|
||||
queryFn: async () => {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const now = new Date();
|
||||
const isToday = startOfDay.toDateString() === now.toDateString();
|
||||
|
||||
const res = await getLiveTvApi(api!).getPrograms({
|
||||
getProgramsDto: {
|
||||
MaxStartDate: endOfDay.toISOString(),
|
||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||
Boolean
|
||||
) as string[],
|
||||
ImageTypeLimit: 1,
|
||||
EnableImages: false,
|
||||
SortBy: ["StartDate"],
|
||||
EnableTotalRecordCount: false,
|
||||
EnableUserData: false,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!channels,
|
||||
});
|
||||
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const [scrollX, setScrollX] = useState(0);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<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]">
|
||||
<View
|
||||
style={{
|
||||
height: HOUR_HEIGHT,
|
||||
}}
|
||||
className="bg-neutral-800"
|
||||
></View>
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
||||
<ItemImage
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
item={c}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<ScrollView
|
||||
style={{
|
||||
width: screenWidth - 64,
|
||||
}}
|
||||
horizontal
|
||||
scrollEnabled
|
||||
onScroll={(e) => {
|
||||
setScrollX(e.nativeEvent.contentOffset.x);
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<HourHeader height={HOUR_HEIGHT} />
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<MemoizedLiveTVGuideRow
|
||||
channel={c}
|
||||
programs={programs?.Items}
|
||||
key={c.Id}
|
||||
scrollX={scrollX}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "recommended"]}
|
||||
title={"On now"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||
userId: user?.Id,
|
||||
isAiring: true,
|
||||
limit: 24,
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "shows"]}
|
||||
title={"Shows"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: false,
|
||||
isSeries: true,
|
||||
isSports: false,
|
||||
isNews: false,
|
||||
isKids: false,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "movies"]}
|
||||
title={"Movies"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "sports"]}
|
||||
title={"Sports"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isSports: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "kids"]}
|
||||
title={"For Kids"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isKids: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "news"]}
|
||||
title={"News"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isNews: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
return (
|
||||
<View className="flex items-center justify-center h-full -mt-12">
|
||||
<Text>Coming soon</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
getSortByPreference,
|
||||
getSortOrderPreference,
|
||||
sortByAtom,
|
||||
SortByOption,
|
||||
sortByPreferenceAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
SortOrderOption,
|
||||
sortOrderOptions,
|
||||
sortOrderPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { libraryId } = searchParams as { libraryId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||
sortOrderPreferenceAtom
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
|
||||
useEffect(() => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sop) {
|
||||
_setSortOrder([sop]);
|
||||
} else {
|
||||
_setSortOrder([SortOrderOption.Ascending]);
|
||||
}
|
||||
const obp = getSortByPreference(libraryId, sortByPreference);
|
||||
if (obp) {
|
||||
_setSortBy([obp]);
|
||||
} else {
|
||||
_setSortBy([SortByOption.SortName]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setSortBy = useCallback(
|
||||
(sortBy: SortByOption[]) => {
|
||||
const sop = getSortByPreference(libraryId, sortByPreference);
|
||||
if (sortBy[0] !== sop) {
|
||||
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
|
||||
}
|
||||
_setSortBy(sortBy);
|
||||
},
|
||||
[libraryId, sortByPreference]
|
||||
);
|
||||
|
||||
const setSortOrder = useCallback(
|
||||
(sortOrder: SortOrderOption[]) => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sortOrder[0] !== sop) {
|
||||
setOderByPreference({
|
||||
...sortOrderPreference,
|
||||
[libraryId]: sortOrder[0],
|
||||
});
|
||||
}
|
||||
_setSortOrder(sortOrder);
|
||||
},
|
||||
[libraryId, sortOrderPreference]
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (screenWidth < 300) return 2;
|
||||
if (screenWidth < 500) return 3;
|
||||
if (screenWidth < 800) return 5;
|
||||
if (screenWidth < 1000) return 6;
|
||||
if (screenWidth < 1500) return 7;
|
||||
return 6;
|
||||
}, [screenWidth, orientation]);
|
||||
|
||||
const { data: library, isLoading: isLibraryLoading } = useQuery({
|
||||
queryKey: ["library", libraryId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: libraryId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!libraryId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: library?.Name || "",
|
||||
});
|
||||
}, [library]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): 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";
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
limit: 36,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0]],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
// 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;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
libraryId,
|
||||
library,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [
|
||||
"library-items",
|
||||
libraryId,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!library,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
? index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
{/* <MoviePoster item={item} /> */}
|
||||
<ItemPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className="">
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title="Years"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title="Sort By"
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title="Sort Order"
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
libraryId,
|
||||
api,
|
||||
user?.Id,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
selectedYears,
|
||||
setSelectedYears,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
]
|
||||
);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading || isLibraryLoading)
|
||||
return (
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (flatData.length === 0)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">No items found</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
key={orientation}
|
||||
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"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={244}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={1}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Page);
|
||||
@@ -1,210 +0,0 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
if (!settings?.libraryOptions) return null;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Library",
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Ionicons
|
||||
name="ellipsis-horizontal-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
align={"end"}
|
||||
alignOffset={-10}
|
||||
avoidCollisions={false}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
side={"bottom"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.Label>Display</DropdownMenu.Label>
|
||||
<DropdownMenu.Group key="display-group">
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
Display
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-1"
|
||||
value={settings.libraryOptions.display === "row"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "row",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-1">
|
||||
Row
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-2"
|
||||
value={settings.libraryOptions.display === "list"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "list",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-2">
|
||||
List
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
Image style
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="poster-option"
|
||||
value={settings.libraryOptions.imageStyle === "poster"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "poster",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="poster-title">
|
||||
Poster
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="cover-option"
|
||||
value={settings.libraryOptions.imageStyle === "cover"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "cover",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="cover-title">
|
||||
Cover
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Group key="show-titles-group">
|
||||
<DropdownMenu.CheckboxItem
|
||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||
key="show-titles-option"
|
||||
value={settings.libraryOptions.showTitles}
|
||||
onValueChange={(newValue) => {
|
||||
if (settings.libraryOptions.imageStyle === "poster")
|
||||
return;
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||
Show titles
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="show-stats-option"
|
||||
value={settings.libraryOptions.showStats}
|
||||
onValueChange={(newValue) => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||
Show stats
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Group>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[libraryId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
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.Screen
|
||||
name="collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function index() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const [settings] = useSettings();
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000 * 60,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
for (const item of data || []) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["library", item.Id],
|
||||
queryFn: async () => {
|
||||
if (!item.Id || !user?.Id || !api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: item.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!data)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
extraData={settings}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||
paddingBottom: 150,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
data={data}
|
||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
ItemSeparatorComponent={() =>
|
||||
settings?.libraryOptions?.display === "row" ? (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className="bg-neutral-800 mx-2 my-4"
|
||||
></View>
|
||||
) : (
|
||||
<View className="h-4" />
|
||||
)
|
||||
}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
"Avengers",
|
||||
"Game of Thrones",
|
||||
"Breaking Bad",
|
||||
"Stranger Things",
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
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);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (q && q.length > 0) setSearch(q);
|
||||
}, [q]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
async ({
|
||||
types,
|
||||
query,
|
||||
}: {
|
||||
types: BaseItemKind[];
|
||||
query: string;
|
||||
}): Promise<BaseItemDto[]> => {
|
||||
if (!api || !query) return [];
|
||||
|
||||
try {
|
||||
if (searchEngine === "Jellyfin") {
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
});
|
||||
|
||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||
} else {
|
||||
if (!settings?.marlinServerUrl) return [];
|
||||
const url = `${
|
||||
settings.marlinServerUrl
|
||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) return [];
|
||||
|
||||
const response2 = await getItemsApi(api).getItems({
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return (response2.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during search:", error);
|
||||
return []; // Ensure an empty array is returned in case of an error
|
||||
}
|
||||
},
|
||||
[api, searchEngine, settings]
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useLayoutEffect(() => {
|
||||
if (Platform.OS === "ios")
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search...",
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: true,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const { data: movies, isFetching: l1 } = useQuery({
|
||||
queryKey: ["search", "movies", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Movie"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
||||
queryKey: ["search", "jellyseerrResults", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
const response = await jellyseerrApi?.search({
|
||||
query: new URLSearchParams(debouncedSearch).toString(),
|
||||
page: 1, // todo: maybe rework page & page-size if first results are not enough...
|
||||
language: "en",
|
||||
});
|
||||
|
||||
return response?.results;
|
||||
},
|
||||
enabled:
|
||||
!!jellyseerrApi &&
|
||||
searchType === "Discover" &&
|
||||
debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
|
||||
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
|
||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||
enabled:
|
||||
!!jellyseerrApi &&
|
||||
searchType === "Discover" &&
|
||||
debouncedSearch.length == 0,
|
||||
});
|
||||
|
||||
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.MOVIE
|
||||
) as MovieResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.TV
|
||||
) as TvResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const { data: series, isFetching: l2 } = useQuery({
|
||||
queryKey: ["search", "series", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Series"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: episodes, isFetching: l3 } = useQuery({
|
||||
queryKey: ["search", "episodes", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Episode"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: collections, isFetching: l7 } = useQuery({
|
||||
queryKey: ["search", "collections", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["BoxSet"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: actors, isFetching: l8 } = useQuery({
|
||||
queryKey: ["search", "actors", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Person"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: artists, isFetching: l4 } = useQuery({
|
||||
queryKey: ["search", "artists", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicArtist"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: albums, isFetching: l5 } = useQuery({
|
||||
queryKey: ["search", "albums", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicAlbum"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: songs, isFetching: l6 } = useQuery({
|
||||
queryKey: ["search", "songs", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Audio"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const noResults = useMemo(() => {
|
||||
return !(
|
||||
artists?.length ||
|
||||
albums?.length ||
|
||||
songs?.length ||
|
||||
movies?.length ||
|
||||
episodes?.length ||
|
||||
series?.length ||
|
||||
collections?.length ||
|
||||
actors?.length ||
|
||||
jellyseerrMovieResults?.length ||
|
||||
jellyseerrTvResults?.length
|
||||
);
|
||||
}, [
|
||||
artists,
|
||||
episodes,
|
||||
albums,
|
||||
songs,
|
||||
movies,
|
||||
series,
|
||||
collections,
|
||||
actors,
|
||||
jellyseerrResults,
|
||||
]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
|
||||
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
keyboardDismissMode="on-drag"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col pt-2">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
keyboardType="web-search"
|
||||
placeholder="Search here..."
|
||||
value={search}
|
||||
onChangeText={(text) => setSearch(text)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{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-neutral-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text="Discover"
|
||||
textClass="p-1"
|
||||
className={
|
||||
searchType === "Discover" ? "bg-neutral-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</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>
|
||||
</View>
|
||||
)}
|
||||
{searchType === "Library" && (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{searchType === "Discover" && (
|
||||
<>
|
||||
<SearchItemWrapper
|
||||
header="Request Movies"
|
||||
items={jellyseerrMovieResults}
|
||||
renderItem={(item: MovieResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header="Request Series"
|
||||
items={jellyseerrTvResults}
|
||||
renderItem={(item: TvResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<View className="mt-4 flex justify-center items-center">
|
||||
<Loader />
|
||||
</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 && searchType === "Library" ? (
|
||||
<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>
|
||||
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
||||
<View className="flex flex-col px-4">
|
||||
{sortBy?.(
|
||||
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
||||
"order"
|
||||
).map((slide) => (
|
||||
<DiscoverSlide key={slide.id} slide={slide} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Props<T> = {
|
||||
ids?: string[] | null;
|
||||
items?: T[];
|
||||
renderItem: (item: any) => React.ReactNode;
|
||||
header?: string;
|
||||
};
|
||||
|
||||
const SearchItemWrapper = <T extends unknown>({
|
||||
ids,
|
||||
items,
|
||||
renderItem,
|
||||
header,
|
||||
}: PropsWithChildren<Props<T>>) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data, isLoading: l1 } = useQuery({
|
||||
queryKey: ["items", ids],
|
||||
queryFn: async () => {
|
||||
if (!user?.Id || !api || !ids || ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemPromises = ids.map((id) =>
|
||||
getUserItemData({
|
||||
api,
|
||||
userId: user.Id,
|
||||
itemId: id,
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(itemPromises);
|
||||
|
||||
// Filter out null items
|
||||
return results.filter(
|
||||
(item) => item !== null
|
||||
) as unknown as BaseItemDto[];
|
||||
},
|
||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (!data && (!items || items.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
className="px-4 mb-2"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
{data && data?.length > 0
|
||||
? data.map((item) => renderItem(item))
|
||||
: items && items?.length > 0
|
||||
? items.map((i) => renderItem(i))
|
||||
: undefined}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +1,90 @@
|
||||
import React from "react";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { withLayoutContext } from "expo-router";
|
||||
|
||||
import {
|
||||
createNativeBottomTabNavigator,
|
||||
NativeBottomTabNavigationEventMap,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
|
||||
import { router, Tabs } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
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,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
NativeBottomTabNavigationEventMap
|
||||
>(Navigator);
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
const [settings] = useSettings();
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android") {
|
||||
NavigationBar.setBackgroundColorAsync("#121212");
|
||||
NavigationBar.setBorderColorAsync("#121212");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden={false} style="light" />
|
||||
<NativeTabs
|
||||
sidebarAdaptable
|
||||
ignoresTopSafeArea
|
||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
||||
tabBarActiveTintColor={Colors.primary}
|
||||
scrollEdgeAppearance="default"
|
||||
>
|
||||
<NativeTabs.Screen redirect name="index" />
|
||||
<NativeTabs.Screen
|
||||
name="(home)"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/house.fill.png")
|
||||
: () => ({ sfSymbol: "house" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(search)"
|
||||
options={{
|
||||
title: "Search",
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/magnifyingglass.png")
|
||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(libraries)"
|
||||
options={{
|
||||
title: "Library",
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/server.rack.png")
|
||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(custom-links)"
|
||||
options={{
|
||||
title: "Custom Links",
|
||||
// @ts-expect-error
|
||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? () => require("@/assets/icons/list.png")
|
||||
: () => ({ sfSymbol: "list.dash" }),
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
</>
|
||||
<Tabs
|
||||
initialRouteName="home"
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors.tabIconSelected,
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
position: "absolute",
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderTopWidth: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: Platform.OS === "android" ? 8 : 26,
|
||||
height: Platform.OS === "android" ? 58 : 74,
|
||||
},
|
||||
tabBarBackground: () =>
|
||||
Platform.OS === "ios" ? (
|
||||
<BlurView
|
||||
experimentalBlurMethod="dimezisBlurView"
|
||||
intensity={95}
|
||||
style={{
|
||||
...StyleSheet.absoluteFillObject,
|
||||
overflow: "hidden",
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
/>
|
||||
) : undefined,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen redirect name="index" />
|
||||
<Tabs.Screen
|
||||
name="home"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon
|
||||
name={focused ? "home" : "home-outline"}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="search"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Search",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name={focused ? "search" : "search"} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="libraries"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Library",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon
|
||||
name={focused ? "apps" : "apps-outline"}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
50
app/(auth)/(tabs)/home/_layout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, View } from "react-native";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Home",
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
marginRight: Platform.OS === "android" ? 17 : 0,
|
||||
}}
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
>
|
||||
<Feather name="download" color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerRight: () => (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
>
|
||||
<View className="h-10 aspect-square flex items-center justify-center rounded">
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
298
app/(auth)/(tabs)/home/index.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
|
||||
export default function index() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settings, _] = useSettings();
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
if (state.isConnected == false || state.isInternetReachable === false)
|
||||
setIsConnected(false);
|
||||
else setIsConnected(true);
|
||||
});
|
||||
|
||||
NetInfo.fetch().then((state) => {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["resumeItems", user?.Id],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user?.Id,
|
||||
})
|
||||
).data.Items) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: _nextUpData, isLoading: isLoadingNextUp } = useQuery({
|
||||
queryKey: ["nextUp-all", user?.Id],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
})
|
||||
).data.Items) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const nextUpData = useMemo(() => {
|
||||
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
|
||||
}, [_nextUpData]);
|
||||
|
||||
const { data: collections } = useQuery({
|
||||
queryKey: ["collectinos", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const movieCollectionId = useMemo(() => {
|
||||
return collections?.find((c) => c.CollectionType === "movies")?.Id;
|
||||
}, [collections]);
|
||||
|
||||
const tvShowCollectionId = useMemo(() => {
|
||||
return collections?.find((c) => c.CollectionType === "tvshows")?.Id;
|
||||
}, [collections]);
|
||||
|
||||
const {
|
||||
data: recentlyAddedInMovies,
|
||||
isLoading: isLoadingRecentlyAddedMovies,
|
||||
} = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 50,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
parentId: movieCollectionId,
|
||||
})
|
||||
).data) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id && !!movieCollectionId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: recentlyAddedInTVShows,
|
||||
isLoading: isLoadingRecentlyAddedTVShows,
|
||||
} = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 50,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
parentId: tvShowCollectionId,
|
||||
})
|
||||
).data) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id && !!tvShowCollectionId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: suggestions, isLoading: isLoadingSuggestions } = useQuery<
|
||||
BaseItemDto[]
|
||||
>({
|
||||
queryKey: ["suggestions", user?.Id],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
limit: 5,
|
||||
mediaType: ["Video"],
|
||||
})
|
||||
).data.Items) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: mediaListCollections } = 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,
|
||||
});
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["sf_promoted"],
|
||||
});
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["sf_carousel"],
|
||||
});
|
||||
setLoading(false);
|
||||
}, [queryClient, user?.Id]);
|
||||
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
No worries, you can still watch{"\n"}downloaded content.
|
||||
</Text>
|
||||
<View className="mt-4">
|
||||
<Button
|
||||
color="purple"
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify="center"
|
||||
iconRight={
|
||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||
}
|
||||
>
|
||||
Go to downloads
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
Something went wrong.{"\n"}Please log out and in again.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
||||
<LargeMovieCarousel />
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Continue Watching"
|
||||
data={data}
|
||||
loading={isLoading}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Next Up"
|
||||
data={nextUpData}
|
||||
loading={isLoadingNextUp}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
{mediaListCollections?.map((ml) => (
|
||||
<MediaListSection key={ml.Id} collection={ml} />
|
||||
))}
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Recently Added in Movies"
|
||||
data={recentlyAddedInMovies}
|
||||
loading={isLoadingRecentlyAddedMovies}
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Recently Added in TV-Shows"
|
||||
data={recentlyAddedInTVShows}
|
||||
loading={isLoadingRecentlyAddedTVShows}
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Suggestions"
|
||||
data={suggestions}
|
||||
loading={isLoadingSuggestions}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
344
app/(auth)/(tabs)/libraries/[libraryId].tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
currentCollectionIdAtom,
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
sortByOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
||||
|
||||
const isCloseToBottom = ({
|
||||
layoutMeasurement,
|
||||
contentOffset,
|
||||
contentSize,
|
||||
}: NativeScrollEvent) => {
|
||||
const paddingToBottom = 200;
|
||||
return (
|
||||
layoutMeasurement.height + contentOffset.y >=
|
||||
contentSize.height - paddingToBottom
|
||||
);
|
||||
};
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { libraryId } = searchParams as { libraryId: string };
|
||||
|
||||
const [, setCurrentCollectionId] = useAtom(currentCollectionIdAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentCollectionId(libraryId);
|
||||
}, [libraryId]);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
const { data: library } = useQuery({
|
||||
queryKey: ["library", libraryId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: libraryId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
const data = response.data;
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!libraryId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !library) return null;
|
||||
|
||||
const includeItemTypes: BaseItemKind[] = [];
|
||||
|
||||
switch (library?.CollectionType) {
|
||||
case "movies":
|
||||
includeItemTypes.push("Movie");
|
||||
break;
|
||||
case "boxsets":
|
||||
includeItemTypes.push("BoxSet");
|
||||
break;
|
||||
case "tvshows":
|
||||
includeItemTypes.push("Series");
|
||||
break;
|
||||
case "music":
|
||||
includeItemTypes.push("MusicAlbum");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
limit: 66,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
includeItemTypes,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
recursive: true,
|
||||
imageTypeLimit: 1,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
libraryId,
|
||||
library,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"library-items",
|
||||
library,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!library,
|
||||
});
|
||||
|
||||
const type = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
|
||||
}, [data]);
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return data?.pages.flatMap((p) => p?.Items) || [];
|
||||
}, [data]);
|
||||
|
||||
if (!library || !library.CollectionType) return null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (isCloseToBottom(nativeEvent)) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
<View className="mt-4 mb-24">
|
||||
<View className="mb-4">
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="flex flex-row space-x-1 px-3">
|
||||
<ResetFiltersButton />
|
||||
<FilterButton
|
||||
collectionId={libraryId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: type ? [type] : [],
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={(value) => setSelectedGenres(value, libraryId)}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
collectionId={libraryId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: type ? [type] : [],
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={(value) => setSelectedTags(value, libraryId)}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
collectionId={libraryId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: type ? [type] : [],
|
||||
parentId: libraryId,
|
||||
});
|
||||
return (
|
||||
response.data.Years?.sort((a, b) => b - a).map((y) =>
|
||||
y.toString()
|
||||
) || []
|
||||
);
|
||||
}}
|
||||
set={(value) => setSelectedYears(value, libraryId)}
|
||||
values={selectedYears}
|
||||
title="Years"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
icon="sort"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortByFilter"
|
||||
queryFn={async () => {
|
||||
return sortByOptions;
|
||||
}}
|
||||
set={(value) => setSortBy(value, libraryId)}
|
||||
values={sortBy}
|
||||
title="Sort by"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
showSearch={false}
|
||||
/>
|
||||
<FilterButton
|
||||
icon="sort"
|
||||
showSearch={false}
|
||||
collectionId={libraryId}
|
||||
queryKey="orderByFilter"
|
||||
queryFn={async () => {
|
||||
return sortOrderOptions;
|
||||
}}
|
||||
set={(value) => setSortOrder(value, libraryId)}
|
||||
values={sortOrder}
|
||||
title="Order by"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{!type && isFetching && (
|
||||
<Loader
|
||||
style={{
|
||||
marginTop: 300,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
|
||||
{flatData.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
<TouchableItemRouter
|
||||
key={`${item.Id}-${index}`}
|
||||
style={{
|
||||
width: "32%",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
item={item}
|
||||
className={`
|
||||
`}
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)
|
||||
)}
|
||||
{flatData.length % 3 !== 0 && (
|
||||
<View
|
||||
style={{
|
||||
width: "33%",
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -1,8 +1,7 @@
|
||||
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function SearchLayout() {
|
||||
export default function IndexLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
@@ -10,17 +9,14 @@ export default function SearchLayout() {
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Search",
|
||||
headerTitle: "Library",
|
||||
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.Screen
|
||||
name="collections/[collectionId]"
|
||||
name="[libraryId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
@@ -29,10 +25,6 @@ export default function SearchLayout() {
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/page"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
111
app/(auth)/(tabs)/libraries/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { currentCollectionIdAtom } from "@/utils/atoms/filters";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function index() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: 17,
|
||||
paddingBottom: 150,
|
||||
}}
|
||||
data={data}
|
||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
ItemSeparatorComponent={() => <View className="h-4" />}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
library: BaseItemDto;
|
||||
}
|
||||
|
||||
const LibraryItemCard: React.FC<Props> = ({ library }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const [currentCollection, setCurrentCollection] = useAtom(
|
||||
currentCollectionIdAtom
|
||||
);
|
||||
|
||||
const url = useMemo(
|
||||
() =>
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item: library,
|
||||
}),
|
||||
[library]
|
||||
);
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!library.Id) return;
|
||||
setCurrentCollection(library.Id);
|
||||
router.push(`/libraries/${library.Id}`);
|
||||
}}
|
||||
>
|
||||
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: 8,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
<Text className="font-bold text-xl text-start px-4">
|
||||
{library.Name}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Stack} from "expo-router";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function CustomMenuLayout() {
|
||||
export default function SearchLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
@@ -9,9 +9,9 @@ export default function CustomMenuLayout() {
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Custom Links",
|
||||
headerTitle: "Search",
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
431
app/(auth)/(tabs)/search/index.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Href,
|
||||
router,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
usePathname,
|
||||
} from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
"Avengers",
|
||||
"Game of Thrones",
|
||||
"Breaking Bad",
|
||||
"Stranger Things",
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (q && q.length > 0) setSearch(q);
|
||||
}, [q]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
async ({
|
||||
types,
|
||||
query,
|
||||
}: {
|
||||
types: BaseItemKind[];
|
||||
query: string;
|
||||
}): Promise<BaseItemDto[]> => {
|
||||
if (!api) return [];
|
||||
|
||||
if (searchEngine === "Jellyfin") {
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints as BaseItemDto[];
|
||||
} else {
|
||||
const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
|
||||
query
|
||||
)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) return [];
|
||||
|
||||
const response2 = await getItemsApi(api).getItems({
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return response2.data.Items as BaseItemDto[];
|
||||
}
|
||||
},
|
||||
[api, settings]
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useLayoutEffect(() => {
|
||||
if (Platform.OS === "ios")
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search...",
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: true,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const { data: movies, isFetching: l1 } = useQuery({
|
||||
queryKey: ["search", "movies", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Movie"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: series, isFetching: l2 } = useQuery({
|
||||
queryKey: ["search", "series", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Series"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: episodes, isFetching: l3 } = useQuery({
|
||||
queryKey: ["search", "episodes", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Episode"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: artists, isFetching: l4 } = useQuery({
|
||||
queryKey: ["search", "artists", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicArtist"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: albums, isFetching: l5 } = useQuery({
|
||||
queryKey: ["search", "albums", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicAlbum"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: songs, isFetching: l6 } = useQuery({
|
||||
queryKey: ["search", "songs", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Audio"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const noResults = useMemo(() => {
|
||||
return !(
|
||||
artists?.length ||
|
||||
albums?.length ||
|
||||
songs?.length ||
|
||||
movies?.length ||
|
||||
episodes?.length ||
|
||||
series?.length
|
||||
);
|
||||
}, [artists, episodes, albums, songs, movies, series]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return l1 || l2 || l3 || l4 || l5 || l6;
|
||||
}, [l1, l2, l3, l4, l5, l6]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
keyboardDismissMode="on-drag"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-32">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
keyboardType="web-search"
|
||||
placeholder="Search here..."
|
||||
value={search}
|
||||
onChangeText={(text) => setSearch(text)}
|
||||
/>
|
||||
</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>
|
||||
</View>
|
||||
)}
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
onPress={() => router.push(`/items/${item.Id}`)}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header="Series"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/series/${item.Id}`)}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header="Episodes"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/${item.Id}`)}
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
header="Artists"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={albums?.map((m) => m.Id!)}
|
||||
header="Albums"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={songs?.map((m) => m.Id!)}
|
||||
header="Songs"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{loading ? (
|
||||
<View className="mt-4 flex justify-center items-center">
|
||||
<Loader />
|
||||
</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}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
ids?: string[] | null;
|
||||
renderItem: (data: 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-2xl px-4 my-2">{header}</Text>
|
||||
{renderItem(data)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { SongsList } from "@/components/music/SongsList";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
@@ -13,7 +10,6 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -91,31 +87,35 @@ export default function page() {
|
||||
enabled: !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!album) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<ItemImage
|
||||
variant={"Primary"}
|
||||
item={album}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="px-4 mb-8">
|
||||
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{songs?.TotalRecordCount} songs
|
||||
</Text>
|
||||
</View>
|
||||
<View className="px-4">
|
||||
<ScrollView>
|
||||
<View className="px-4 pb-24">
|
||||
<View className="flex flex-row space-x-4 items-start mb-4">
|
||||
<View className="w-24">
|
||||
<ArtistPoster item={album} />
|
||||
</View>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-bold text-3xl">{album?.Name}</Text>
|
||||
<Text className="">{album?.ProductionYear}</Text>
|
||||
|
||||
<View className="flex flex-row space-x-2 mt-1">
|
||||
{album.AlbumArtists?.map((a) => (
|
||||
<TouchableOpacity
|
||||
key={a.Id}
|
||||
onPress={() => {
|
||||
router.push(`/artists/${a.Id}/page`);
|
||||
}}
|
||||
>
|
||||
<Text className="font-bold text-purple-600">
|
||||
{album?.AlbumArtist}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<SongsList
|
||||
albumId={albumId}
|
||||
songs={songs?.Items}
|
||||
@@ -123,6 +123,6 @@ export default function page() {
|
||||
artistId={artistId}
|
||||
/>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,6 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -86,45 +82,50 @@ export default function page() {
|
||||
enabled: !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: albums?.Items?.[0].AlbumArtist,
|
||||
});
|
||||
}, [albums]);
|
||||
|
||||
if (!artist || !albums) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<ItemImage
|
||||
variant={"Primary"}
|
||||
item={artist}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
<FlatList
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: 140,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View className="mb-2">
|
||||
<View className="w-32 mb-4">
|
||||
<ArtistPoster item={artist} />
|
||||
</View>
|
||||
<Text className="font-bold text-2xl mb-4">Albums</Text>
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View className="px-4 mb-8">
|
||||
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{albums.TotalRecordCount} albums
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row flex-wrap justify-between px-4">
|
||||
{albums.Items.map((item, idx) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
style={{ width: "30%", marginBottom: 20 }}
|
||||
key={idx}
|
||||
>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<ArtistPoster item={item} />
|
||||
<Text numberOfLines={2}>{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
nestedScrollEnabled
|
||||
data={albums.Items}
|
||||
numColumns={3}
|
||||
columnWrapperStyle={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
renderItem={({ item, index }) => (
|
||||
<TouchableOpacity
|
||||
style={{ width: "30%" }}
|
||||
key={index}
|
||||
onPress={() => {
|
||||
router.push(`/albums/${item.Id}`);
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<ArtistPoster item={item} />
|
||||
<Text>{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -91,13 +90,15 @@ export default function page() {
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
renderItem={({ item, index }) => (
|
||||
<TouchableItemRouter
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
maxWidth: "30%",
|
||||
width: "100%",
|
||||
}}
|
||||
key={index}
|
||||
item={item}
|
||||
onPress={() => {
|
||||
router.push(`/artists/${item.Id}/page`);
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
{collection?.CollectionType === "movies" && (
|
||||
@@ -109,7 +110,7 @@ export default function page() {
|
||||
<Text>{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
/>
|
||||
353
app/(auth)/collections/[collectionId].tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
currentCollectionIdAtom,
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
sortByOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
||||
|
||||
const isCloseToBottom = ({
|
||||
layoutMeasurement,
|
||||
contentOffset,
|
||||
contentSize,
|
||||
}: NativeScrollEvent) => {
|
||||
const paddingToBottom = 200;
|
||||
return (
|
||||
layoutMeasurement.height + contentOffset.y >=
|
||||
contentSize.height - paddingToBottom
|
||||
);
|
||||
};
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [currentCollection, setCurrentCollection] = useAtom(
|
||||
currentCollectionIdAtom
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSortBy(
|
||||
[
|
||||
{
|
||||
key: "PremiereDate",
|
||||
value: "Premiere Date",
|
||||
},
|
||||
],
|
||||
collectionId
|
||||
);
|
||||
setSortOrder(
|
||||
[
|
||||
{
|
||||
key: "Ascending",
|
||||
value: "Ascending",
|
||||
},
|
||||
],
|
||||
collectionId
|
||||
);
|
||||
}, []);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: collectionId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
const data = response.data;
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!collectionId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: collection?.Name || "" });
|
||||
}, [navigation, collection]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !collection) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 18,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Data: ", data);
|
||||
}, [data]);
|
||||
|
||||
const type = useMemo(() => {
|
||||
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
|
||||
}, [data]);
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return data?.pages.flatMap((p) => p?.Items) || [];
|
||||
}, [data]);
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (isCloseToBottom(nativeEvent)) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
<View className="mt-4 mb-24">
|
||||
<View className="mb-4">
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="flex flex-row space-x-1 px-3">
|
||||
<ResetFiltersButton />
|
||||
<FilterButton
|
||||
collectionId={collectionId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: type ? [type] : [],
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={(value) => setSelectedGenres(value, collectionId)}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
collectionId={collectionId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: type ? [type] : [],
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={(value) => setSelectedTags(value, collectionId)}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
collectionId={collectionId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: type ? [type] : [],
|
||||
parentId: collectionId,
|
||||
});
|
||||
return (
|
||||
response.data.Years?.sort((a, b) => b - a).map((y) =>
|
||||
y.toString()
|
||||
) || []
|
||||
);
|
||||
}}
|
||||
set={(value) => setSelectedYears(value, collectionId)}
|
||||
values={selectedYears}
|
||||
title="Years"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<FilterButton
|
||||
icon="sort"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortByFilter"
|
||||
queryFn={async () => {
|
||||
return sortByOptions;
|
||||
}}
|
||||
set={(value) => setSortBy(value, collectionId)}
|
||||
values={sortBy}
|
||||
title="Sort by"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
showSearch={false}
|
||||
/>
|
||||
<FilterButton
|
||||
icon="sort"
|
||||
showSearch={false}
|
||||
collectionId={collectionId}
|
||||
queryKey="orderByFilter"
|
||||
queryFn={async () => {
|
||||
return sortOrderOptions;
|
||||
}}
|
||||
set={(value) => setSortOrder(value, collectionId)}
|
||||
values={sortOrder}
|
||||
title="Order by"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{!type && isFetching && (
|
||||
<Loader
|
||||
style={{
|
||||
marginTop: 300,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
|
||||
{flatData.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
<TouchableItemRouter
|
||||
key={`${item.Id}`}
|
||||
style={{
|
||||
width: "32%",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
item={item}
|
||||
className={`
|
||||
`}
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)
|
||||
)}
|
||||
{flatData.length % 3 !== 0 && (
|
||||
<View
|
||||
style={{
|
||||
width: "33%",
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
179
app/(auth)/downloads.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const [process, setProcess] = useAtom(runningProcesses);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
|
||||
const { data: downloadedFiles, isLoading } = useQuery({
|
||||
queryKey: ["downloaded_files", process?.item.Id],
|
||||
queryFn: async () =>
|
||||
JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
) as BaseItemDto[],
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const movies = useMemo(
|
||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||
[downloadedFiles]
|
||||
);
|
||||
|
||||
const groupedBySeries = useMemo(() => {
|
||||
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
|
||||
const series: { [key: string]: BaseItemDto[] } = {};
|
||||
episodes?.forEach((e) => {
|
||||
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
|
||||
series[e.SeriesName!].push(e);
|
||||
});
|
||||
return Object.values(series);
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const eta = useMemo(() => {
|
||||
const length = process?.item?.RunTimeTicks || 0;
|
||||
|
||||
if (!process?.speed || !process?.progress) return "";
|
||||
|
||||
const timeLeft =
|
||||
(length - length * (process.progress / 100)) / process.speed;
|
||||
|
||||
return formatNumber(timeLeft / 10000);
|
||||
}, [process]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<View className="px-4 py-4">
|
||||
<View className="mb-4 flex flex-col space-y-4">
|
||||
<View>
|
||||
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{queue.map((q) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/items/${q.item.Id}`)}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
>
|
||||
<View>
|
||||
<Text className="font-semibold">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setQueue((prev) => prev.filter((i) => i.id !== q.id));
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className="opacity-50">No items in queue</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
||||
{process?.item ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/items/${process.item.Id}`)}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
>
|
||||
<View>
|
||||
<Text className="font-semibold">{process.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
{process.item.Type}
|
||||
</Text>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<Text className="text-xs">
|
||||
{process.progress.toFixed(0)}%
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
{process.speed?.toFixed(2)}x
|
||||
</Text>
|
||||
<View>
|
||||
<Text className="text-xs">ETA {eta}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
FFmpegKit.cancel();
|
||||
setProcess(null);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
className={`
|
||||
absolute bottom-0 left-0 h-1 bg-purple-600
|
||||
`}
|
||||
style={{
|
||||
width: process.progress
|
||||
? `${Math.max(5, process.progress)}%`
|
||||
: "5%",
|
||||
}}
|
||||
></View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Text className="opacity-50">No active downloads</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{movies.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2">
|
||||
<Text className="text-2xl font-bold">Movies</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{movies?.map((item: BaseItemDto) => (
|
||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||
<MovieCard item={item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default downloads;
|
||||
|
||||
/*
|
||||
* Format a number (Date.getTime) to a human readable string ex. 2m 34s
|
||||
* @param {number} num - The number to format
|
||||
*
|
||||
* @returns {string} - The formatted string
|
||||
*/
|
||||
const formatNumber = (num: number) => {
|
||||
const minutes = Math.floor(num / 60000);
|
||||
const seconds = ((num % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
};
|
||||
303
app/(auth)/items/[id].tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { Ratings } from "@/components/Ratings";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
fullScreenAtom,
|
||||
playingAtom,
|
||||
showCurrentlyPlayingBarAtom,
|
||||
} from "@/utils/atoms/playState";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { id } = local as { id: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setShowCurrentlyPlayingBar] = useAtom(showCurrentlyPlayingBarAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||
|
||||
const client = useRemoteMediaClient();
|
||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: id,
|
||||
}),
|
||||
enabled: !!id && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", item?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: playbackUrl } = useQuery({
|
||||
queryKey: [
|
||||
"playbackUrl",
|
||||
item?.Id,
|
||||
maxBitrate,
|
||||
castDevice,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !sessionData) return null;
|
||||
|
||||
let deviceProfile: any = ios;
|
||||
|
||||
if (castDevice?.deviceId) {
|
||||
deviceProfile = chromecastProfile;
|
||||
} else if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
} else if (settings?.deviceProfile === "Old") {
|
||||
deviceProfile = old;
|
||||
}
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
sessionData,
|
||||
deviceProfile,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
forceDirectPlay: settings?.forceDirectPlay,
|
||||
});
|
||||
|
||||
console.log("Transcode URL: ", url);
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const onPressPlay = useCallback(
|
||||
async (type: "device" | "cast" = "device") => {
|
||||
if (!playbackUrl || !item) return;
|
||||
|
||||
if (type === "cast" && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: playbackUrl,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCurrentlyPlying({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
setShowCurrentlyPlayingBar(true);
|
||||
|
||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||
setTimeout(() => {
|
||||
setFullscreen(true);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
},
|
||||
[playbackUrl, item, settings]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
||||
[item]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item?.Id || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col px-4 pt-4">
|
||||
<View className="flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<SeriesTitleHeader item={item} />
|
||||
) : (
|
||||
<>
|
||||
<MoviesTitleHeader item={item} />
|
||||
</>
|
||||
)}
|
||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||
<Ratings item={item} />
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row justify-between items-center mb-2">
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
)}
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
|
||||
<OverviewText text={item.Overview} />
|
||||
</View>
|
||||
<View className="flex flex-col p-4 w-full">
|
||||
<View className="flex flex-row items-center space-x-2 w-full">
|
||||
<BitrateSelector
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between w-full">
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<CastAndCrew item={item} />
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<View className="mb-4">
|
||||
<CurrentSeries item={item} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
|
||||
<View className="h-12"></View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Stack } from "expo-router";
|
||||
import React from "react";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden />
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="direct-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="transcoding-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="music-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||
import {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import native from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
BackHandler,
|
||||
View,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import settings from "../(tabs)/(home)/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const { getDownloadedItem } = useDownload();
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
offline: offlineStr,
|
||||
} = useGlobalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
offline: string;
|
||||
}>();
|
||||
const [settings] = useSettings();
|
||||
const offline = offlineStr === "true";
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? parseInt(bitrateValueStr, 10)
|
||||
: BITRATES[0].value;
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
const item = await getDownloadedItem(itemId);
|
||||
if (item) return item.item;
|
||||
}
|
||||
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!itemId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
const data = await getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return null;
|
||||
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
|
||||
if (item)
|
||||
return {
|
||||
mediaSource: data.mediaSource,
|
||||
url,
|
||||
sessionId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: native,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
Alert.alert("Error", "Failed to get stream url");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
};
|
||||
},
|
||||
enabled: !!itemId && !!item,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
if (!api) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
|
||||
if (!offline && stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: msToTicks(progress.value),
|
||||
isPaused: true,
|
||||
playMethod: stream.url?.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Actually marked as paused");
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
if (!offline && stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: msToTicks(progress.value),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
stream,
|
||||
videoRef,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
offline,
|
||||
progress.value,
|
||||
]);
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (offline) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.value);
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream?.sessionId!,
|
||||
});
|
||||
|
||||
revalidateProgressCache();
|
||||
}, [api, item, mediaSourceId, stream]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
// TODO: unused should remove.
|
||||
const reportPlaybackStart = useCallback(async () => {
|
||||
if (offline) return;
|
||||
|
||||
if (!stream) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
||||
});
|
||||
}, [api, item, mediaSourceId, stream]);
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const { currentTime } = data.nativeEvent;
|
||||
|
||||
if (isBuffering) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
|
||||
progress.value = currentTime;
|
||||
|
||||
if (offline) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(currentTime);
|
||||
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(currentTimeInTicks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
},
|
||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||
);
|
||||
|
||||
useOrientation();
|
||||
useOrientationSettings();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline,
|
||||
});
|
||||
|
||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
|
||||
if (state === "Playing") {
|
||||
setIsPlaying(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "Paused") {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
} else if (isBuffering) {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPosition = useMemo(() => {
|
||||
if (offline) return 0;
|
||||
|
||||
return item?.UserData?.PlaybackPositionTicks
|
||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||
: 0;
|
||||
}, [item]);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
return async () => {
|
||||
stop();
|
||||
console.log("Unmounted");
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
const [appState, setAppState] = useState(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||
console.log("App has come to the foreground!");
|
||||
// Handle app coming to the foreground
|
||||
} else if (nextAppState.match(/inactive|background/)) {
|
||||
console.log("App has gone to the background!");
|
||||
// Handle app going to the background
|
||||
if (videoRef.current && videoRef.current.pause) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}
|
||||
setAppState(nextAppState);
|
||||
};
|
||||
|
||||
// Use AppState.addEventListener and return a cleanup function
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
handleAppStateChange
|
||||
);
|
||||
|
||||
return () => {
|
||||
// Cleanup the event listener when the component is unmounted
|
||||
subscription.remove();
|
||||
};
|
||||
}, [appState]);
|
||||
|
||||
// Preselection of audio and subtitle tracks.
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||
|
||||
const allSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub) => sub.Type === "Subtitle"
|
||||
) || [];
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex
|
||||
);
|
||||
const allAudio =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(audio) => audio.Type === "Audio"
|
||||
) || [];
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
|
||||
// Direct playback CASE
|
||||
if (!bitrateValue) {
|
||||
// If Subtitle is embedded we can use the position to select it straight away.
|
||||
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
||||
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
||||
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
||||
// If Subtitle is external we need to pass the URL to the player.
|
||||
externalTrack = {
|
||||
name: chosenSubtitleTrack.DisplayTitle || "",
|
||||
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (chosenAudioTrack)
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
} else {
|
||||
// Transcoded playback CASE
|
||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
||||
externalTrack = {
|
||||
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
||||
DeliveryUrl: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">Error</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
||||
}}
|
||||
>
|
||||
<VlcPlayerView
|
||||
ref={videoRef}
|
||||
source={{
|
||||
uri: stream.url,
|
||||
autoplay: true,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
externalTrack,
|
||||
initOptions,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onVideoProgress={onProgress}
|
||||
progressUpdateInterval={1000}
|
||||
onVideoStateChange={onPlaybackStateChanged}
|
||||
onVideoLoadStart={() => {}}
|
||||
onVideoLoadEnd={() => {
|
||||
setIsVideoLoaded(true);
|
||||
}}
|
||||
onVideoError={(e) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"An error occurred while playing the video. Check logs in settings."
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{videoRef.current && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
play={videoRef.current?.play}
|
||||
pause={videoRef.current?.pause}
|
||||
seek={videoRef.current?.seekTo}
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||
offline={offline}
|
||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
stop={stop}
|
||||
isVlc
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePoster(
|
||||
item: BaseItemDto,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
return item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Image } from "expo-image";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
|
||||
export default function page() {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const windowDimensions = useWindowDimensions();
|
||||
|
||||
const firstTime = useRef(true);
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr
|
||||
? parseInt(subtitleIndexStr, 10)
|
||||
: undefined;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? parseInt(bitrateValueStr, 10)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (!api) return;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!itemId && !!api,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url"],
|
||||
queryFn: async () => {
|
||||
if (!api) return;
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) return null;
|
||||
|
||||
return {
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const poster = usePoster(item, api);
|
||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ticks: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: true,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
videoRef,
|
||||
settings,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
]
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
console.log("stop");
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
}, [videoRef]);
|
||||
|
||||
const seek = useCallback(
|
||||
(seconds: number) => {
|
||||
videoRef.current?.seek(seconds);
|
||||
},
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item?.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = data.currentTime * 10000000;
|
||||
|
||||
progress.value = secondsToTicks(data.currentTime);
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!item?.Id || data.currentTime === 0) return;
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
},
|
||||
[
|
||||
item,
|
||||
isPlaying,
|
||||
api,
|
||||
isPlaybackStopped,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
]
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
useOrientation();
|
||||
useOrientationSettings();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
playVideo: play,
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
if (isLoadingItem || isLoadingStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">Error</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item || !stream)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">Error</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: windowDimensions.width,
|
||||
height: windowDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
||||
<Image
|
||||
source={poster}
|
||||
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowControls(!showControls);
|
||||
}}
|
||||
className="absolute z-0 h-full w-full opacity-0"
|
||||
>
|
||||
{videoSource && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={() => {}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={500}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Controls
|
||||
item={item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
enableTrickplay={false}
|
||||
pause={pause}
|
||||
play={play}
|
||||
seek={seek}
|
||||
isVlc={false}
|
||||
stop={stop}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePoster(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
return item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
url?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!item || !api || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: item?.AlbumArtist ?? undefined,
|
||||
title: item?.Name || "Unknown",
|
||||
description: item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [item, api, poster]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
@@ -1,560 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import transcoding from "@/utils/profiles/transcoding";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
SelectedTrack,
|
||||
SelectedTrackType,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
|
||||
const Player = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
|
||||
const firstTime = useRef(true);
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}, []);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr
|
||||
? parseInt(subtitleIndexStr, 10)
|
||||
: undefined;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? parseInt(bitrateValueStr, 10)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error("No api");
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
console.warn("No itemId");
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
||||
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
||||
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error("No api");
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
console.warn("No item", itemId, item);
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: transcoding,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
console.warn("No sessionId or mediaSource or url", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
};
|
||||
},
|
||||
enabled: !!item,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const poster = usePoster(item, api);
|
||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: true,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
videoRef,
|
||||
settings,
|
||||
stream,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const seek = useCallback(
|
||||
(seconds: number) => {
|
||||
videoRef.current?.seek(seconds);
|
||||
},
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
revalidateProgressCache();
|
||||
};
|
||||
|
||||
const stop = useCallback(() => {
|
||||
reportPlaybackStopped();
|
||||
videoRef.current?.pause();
|
||||
setIsPlaybackStopped(true);
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = secondsToTicks(data.currentTime);
|
||||
|
||||
progress.value = ticks;
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
|
||||
console.log(
|
||||
"onProgress ~",
|
||||
ticks,
|
||||
isPlaying,
|
||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||
);
|
||||
|
||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||
// TODO: since playable duration is always 0 then.
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!item?.Id || data.currentTime === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
},
|
||||
[
|
||||
item,
|
||||
isPlaying,
|
||||
api,
|
||||
isPlaybackStopped,
|
||||
isSeeking,
|
||||
stream,
|
||||
mediaSourceId,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
]
|
||||
);
|
||||
|
||||
useOrientation();
|
||||
useOrientationSettings();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline: false,
|
||||
});
|
||||
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
||||
SelectedTrack | undefined
|
||||
>();
|
||||
|
||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
||||
{
|
||||
index: number;
|
||||
language?: string | undefined;
|
||||
selected?: boolean | undefined;
|
||||
title?: string | undefined;
|
||||
type: any;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
||||
SelectedTrack | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTextTrack === undefined) {
|
||||
const subtitleHelper = new SubtitleHelper(
|
||||
stream?.mediaSource.MediaStreams ?? []
|
||||
);
|
||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
||||
subtitleIndex!
|
||||
);
|
||||
|
||||
// Most likely the subtitle is burned in.
|
||||
if (embeddedTrackIndex === -1) return;
|
||||
console.log(
|
||||
"Setting selected text track",
|
||||
subtitleIndex,
|
||||
embeddedTrackIndex
|
||||
);
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: embeddedTrackIndex,
|
||||
});
|
||||
}
|
||||
}, [embededTextTracks]);
|
||||
|
||||
const getAudioTracks = (): TrackInfo[] => {
|
||||
return audioTracks.map((t) => ({
|
||||
name: t.name,
|
||||
index: t.index,
|
||||
}));
|
||||
};
|
||||
|
||||
const getSubtitleTracks = (): TrackInfo[] => {
|
||||
return embededTextTracks.map((t) => ({
|
||||
name: t.title ?? "",
|
||||
index: t.index,
|
||||
language: t.language,
|
||||
}));
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
return async () => {
|
||||
stop();
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
if (isLoadingItem || isLoadingStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">Error</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
opacity: showControls ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{videoSource ? (
|
||||
<>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={(e) => {
|
||||
console.error("Error playing video", e);
|
||||
}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={500}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
onTextTracks={(data) => {
|
||||
setEmbededTextTracks(data.textTracks as any);
|
||||
}}
|
||||
onBuffer={(e) => {
|
||||
setIsBuffering(e.isBuffering);
|
||||
}}
|
||||
onAudioTracks={(e) => {
|
||||
console.log("onAudioTracks: ", e.audioTracks);
|
||||
setAudioTracks(
|
||||
e.audioTracks.map((t) => ({
|
||||
index: t.index,
|
||||
name: t.title ?? "",
|
||||
language: t.language,
|
||||
}))
|
||||
);
|
||||
}}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
selectedAudioTrack={selectedAudioTrack}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Text>No video source...</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{item && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
videoRef={videoRef}
|
||||
enableTrickplay={true}
|
||||
item={item}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
seek={seek}
|
||||
play={play}
|
||||
pause={pause}
|
||||
stop={stop}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setSubtitleTrack={(i) => {
|
||||
if (i === -1) {
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.DISABLED,
|
||||
value: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
});
|
||||
}}
|
||||
getAudioTracks={getAudioTracks}
|
||||
setAudioTrack={(i) => {
|
||||
console.log("setAudioTrack ~", i);
|
||||
setSelectedAudioTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export function usePoster(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
return item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
url?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!item || !api || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: item?.AlbumArtist ?? undefined,
|
||||
title: item?.Name || "Unknown",
|
||||
description: item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [item, api, poster, url]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
|
||||
export default Player;
|
||||
@@ -1,26 +1,19 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { Ratings } from "@/components/Ratings";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
import { SeriesActions } from "@/components/series/SeriesActions";
|
||||
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 } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const { id: seriesId, seasonIndex } = params as {
|
||||
id: string;
|
||||
@@ -50,7 +43,7 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
[item],
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
@@ -59,55 +52,13 @@ const page: React.FC = () => {
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item]
|
||||
[item],
|
||||
);
|
||||
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["AllEpisodes", item?.Id],
|
||||
queryFn: async () => {
|
||||
const res = await getTvShowsApi(api!).getEpisodes({
|
||||
seriesId: item?.Id!,
|
||||
userId: user?.Id!,
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
!isLoading &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadItems
|
||||
title="Download Series"
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="download" size={22} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={24}
|
||||
color="#9333ea"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [allEpisodes, isLoading]);
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
@@ -136,12 +87,15 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4">
|
||||
<SeriesHeader item={item} />
|
||||
<View className="flex flex-col pt-4 pb-24">
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||
<SeasonPicker item={item} />
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
90
app/(auth)/settings.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/ListItem";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs, readFromLog } from "@/utils/log";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import { WebSocketsTest } from "@/components/settings/WebsocketsText";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
const { deleteAllFiles } = useFiles();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: logs } = useQuery({
|
||||
queryKey: ["logs"],
|
||||
queryFn: async () => readFromLog(),
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<View className="p-4 flex flex-col gap-y-4 pb-12">
|
||||
<Text className="font-bold text-2xl">Information</Text>
|
||||
|
||||
<View className="flex flex-col rounded-xl mb-4 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} />
|
||||
</View>
|
||||
|
||||
<SettingToggles />
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Button color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all logs
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text className="font-bold text-2xl">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 className="text-xs">{log.message}</Text>
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<Text className="opacity-50">No logs available</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
281
app/(auth)/songs/[songId].tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { songId: id } = local as { songId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View className="">
|
||||
<Chromecast />
|
||||
</View>
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: id,
|
||||
}),
|
||||
enabled: !!id && !!api,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
||||
[item]
|
||||
);
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", item?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: playbackUrl } = useQuery({
|
||||
queryKey: [
|
||||
"playbackUrl",
|
||||
item?.Id,
|
||||
maxBitrate,
|
||||
castDevice,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !sessionData) return null;
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
});
|
||||
|
||||
console.log("Transcode URL: ", url);
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!sessionData,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const client = useRemoteMediaClient();
|
||||
|
||||
const onPressPlay = useCallback(
|
||||
async (type: "device" | "cast" = "device") => {
|
||||
if (!playbackUrl || !item) return;
|
||||
|
||||
if (type === "cast" && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: playbackUrl,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
}
|
||||
},
|
||||
[playbackUrl, item]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item?.Id || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col px-4 pt-4">
|
||||
<View className="flex flex-col">
|
||||
<MoviesTitleHeader item={item} />
|
||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-col p-4 w-full">
|
||||
<View className="flex flex-row items-center space-x-2 w-full">
|
||||
<BitrateSelector
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between w-full">
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton
|
||||
item={item}
|
||||
chromecastReady={chromecastReady}
|
||||
onPress={onPressPlay}
|
||||
className="grow"
|
||||
/>
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal className="flex px-4 mb-4">
|
||||
<View className="flex flex-row space-x-2 ">
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-sm opacity-70">Audio</Text>
|
||||
</View>
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-sm opacity-70">
|
||||
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
|
||||
<View className="h-12"></View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Button, Dimensions } from "react-native";
|
||||
import { Alert, View } from "react-native";
|
||||
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useGlobalSearchParams();
|
||||
const navigation = useNavigation();
|
||||
console.log(searchParams);
|
||||
|
||||
const { url } = searchParams as { url: string };
|
||||
|
||||
const videoId = useMemo(() => {
|
||||
return url.split("v=")[1];
|
||||
}, [url]);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
||||
if (state === "ended") {
|
||||
setPlaying(false);
|
||||
Alert.alert("video has finished playing!");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlaying = useCallback(() => {
|
||||
setPlaying((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShown: false,
|
||||
});
|
||||
|
||||
togglePlaying();
|
||||
}, []);
|
||||
|
||||
const screenWidth = Dimensions.get("screen").width;
|
||||
const screenHeight = Dimensions.get("screen").height;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col bg-black items-center justify-center h-full">
|
||||
<YoutubePlayer
|
||||
height={300}
|
||||
play={playing}
|
||||
videoId={videoId}
|
||||
onChangeState={onStateChange}
|
||||
width={screenWidth}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
469
app/_layout.tsx
@@ -1,211 +1,28 @@
|
||||
import "@/augmentations";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStorage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
import { LogProvider, writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
checkForExistingDownloads,
|
||||
completeHandler,
|
||||
download,
|
||||
} from "@kesha-antonov/react-native-background-downloader";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as BackgroundFetch from "expo-background-fetch";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useFonts } from "expo-font";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { router, Stack } from "expo-router";
|
||||
import { Stack } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Appearance, AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
function useNotificationObserver() {
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
function redirect(notification: Notifications.Notification) {
|
||||
const url = notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
});
|
||||
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
redirect(response.notification);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload || !url)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader: token,
|
||||
url,
|
||||
});
|
||||
|
||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||
|
||||
for (let job of jobs) {
|
||||
if (job.status === "completed") {
|
||||
const downloadUrl = url + "download/" + job.id;
|
||||
const tasks = await checkForExistingDownloads();
|
||||
|
||||
if (tasks.find((task) => task.id === job.id)) {
|
||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
download({
|
||||
id: job.id,
|
||||
url: downloadUrl,
|
||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
.begin(() => {
|
||||
console.log("TaskManager ~ Download started: ", job.id);
|
||||
})
|
||||
.done(() => {
|
||||
console.log("TaskManager ~ Download completed: ", job.id);
|
||||
saveDownloadedItemInfo(job.item);
|
||||
completeHandler(job.id);
|
||||
cancelJobById({
|
||||
authHeader: token,
|
||||
id: job.id,
|
||||
url: url,
|
||||
});
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download completed",
|
||||
data: {
|
||||
url: `/downloads`,
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
})
|
||||
.error((error) => {
|
||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||
completeHandler(job.id);
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download failed",
|
||||
data: {
|
||||
url: `/downloads`,
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||
|
||||
// Be sure to return the successful result type!
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
});
|
||||
|
||||
const checkAndRequestPermissions = async () => {
|
||||
try {
|
||||
const hasAskedBefore = storage.getString(
|
||||
"hasAskedForNotificationPermission"
|
||||
);
|
||||
|
||||
if (hasAskedBefore !== "true") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
|
||||
if (status === "granted") {
|
||||
writeToLog("INFO", "Notification permissions granted.");
|
||||
console.log("Notification permissions granted.");
|
||||
} else {
|
||||
writeToLog("ERROR", "Notification permissions denied.");
|
||||
console.log("Notification permissions denied.");
|
||||
}
|
||||
|
||||
storage.set("hasAskedForNotificationPermission", "true");
|
||||
} else {
|
||||
console.log("Already asked for notification permissions before.");
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Error checking/requesting notification permissions:",
|
||||
error
|
||||
);
|
||||
console.error("Error checking/requesting notification permissions:", error);
|
||||
}
|
||||
export const unstable_settings = {
|
||||
initialRouteName: "/index",
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
@@ -219,8 +36,6 @@ export default function RootLayout() {
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
Appearance.setColorScheme("dark");
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
@@ -232,28 +47,24 @@ export default function RootLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function Layout() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||
|
||||
useKeepAwake();
|
||||
useNotificationObserver();
|
||||
|
||||
useEffect(() => {
|
||||
checkAndRequestPermissions();
|
||||
}, []);
|
||||
const queryClientRef = useRef<QueryClient>(
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.autoRotate === true)
|
||||
@@ -264,139 +75,113 @@ function Layout() {
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
const appState = useRef(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
checkForExistingDownloads();
|
||||
}
|
||||
});
|
||||
|
||||
checkForExistingDownloads();
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const url = Linking.useURL();
|
||||
|
||||
if (url) {
|
||||
const { hostname, path, queryParams } = Linking.parse(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActionSheetProvider>
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/trailer/page"
|
||||
options={{
|
||||
presentation: "modal",
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</JobQueueProvider>
|
||||
</ActionSheetProvider>
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<JobQueueProvider>
|
||||
<ActionSheetProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaybackProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/settings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/downloads"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/[artistId]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/albums/[albumId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/songs/[songId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/series/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</PlaybackProvider>
|
||||
</JellyfinProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ActionSheetProvider>
|
||||
</JobQueueProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: BaseItemDto[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = item;
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
}
|
||||
}
|
||||
|
||||
244
app/login.tsx
@@ -3,18 +3,14 @@ import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } 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, useNavigation } from "expo-router";
|
||||
import { AxiosError } from "axios";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
@@ -25,64 +21,19 @@ const CredentialsSchema = z.object({
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||
useJellyfin();
|
||||
const { setServer, login, removeServer } = useJellyfin();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
username: _username,
|
||||
password: _password,
|
||||
} = params as { apiUrl: string; username: string; password: string };
|
||||
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
const [serverURL, setServerURL] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
}>({
|
||||
username: _username,
|
||||
password: _password,
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// we might re-use the checkUrl function here to check the url as well
|
||||
// however, I don't think it should be necessary for now
|
||||
if (_apiUrl) {
|
||||
setServer({
|
||||
address: _apiUrl,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (_username && _password) {
|
||||
setCredentials({ username: _username, password: _password });
|
||||
login(_username, _password);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: serverName,
|
||||
headerLeft: () =>
|
||||
api?.basePath ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : null,
|
||||
});
|
||||
}, [serverName, navigation, api?.basePath]);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
@@ -103,88 +54,12 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Checks the availability and validity of a Jellyfin server URL.
|
||||
*
|
||||
* This function attempts to connect to a Jellyfin server using the provided URL.
|
||||
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
|
||||
*
|
||||
* @param {string} url - The base URL of the Jellyfin server to check.
|
||||
* @returns {Promise<string | undefined>} A Promise that resolves to:
|
||||
* - The full URL (including protocol) if a valid Jellyfin server is found.
|
||||
* - undefined if no valid server is found at the given URL.
|
||||
*
|
||||
* Side effects:
|
||||
* - 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) {
|
||||
setLoadingServerCheck(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${url}/System/Info/Public`, {
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
setServerName(data.ServerName || "");
|
||||
return url;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoadingServerCheck(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the connection attempt to a Jellyfin server.
|
||||
*
|
||||
* This function trims the input URL, checks its validity using the `checkUrl` function,
|
||||
* and sets the server address if a valid connection is established.
|
||||
*
|
||||
* @param {string} url - The URL of the Jellyfin server to connect to.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*
|
||||
* Side effects:
|
||||
* - Calls `checkUrl` to validate the server URL.
|
||||
* - Shows an alert if the connection fails.
|
||||
* - Sets the server address using `setServer` if the connection is successful.
|
||||
*
|
||||
*/
|
||||
const handleConnect = async (url: string) => {
|
||||
url = url.trim();
|
||||
|
||||
const result = await checkUrl(url);
|
||||
|
||||
if (result === undefined) {
|
||||
Alert.alert(
|
||||
"Connection failed",
|
||||
"Could not connect to the server. Please check the URL and your network connection."
|
||||
);
|
||||
const handleConnect = (url: string) => {
|
||||
if (!url.startsWith("http")) {
|
||||
Alert.alert("Error", "URL needs to start with http or https.");
|
||||
return;
|
||||
}
|
||||
|
||||
setServer({ address: url });
|
||||
};
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
try {
|
||||
const code = await initiateQuickConnect();
|
||||
if (code) {
|
||||
Alert.alert("Quick Connect", `Enter code ${code} to login`, [
|
||||
{
|
||||
text: "Got It",
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to initiate Quick Connect");
|
||||
}
|
||||
setServer({ address: url.trim() });
|
||||
};
|
||||
|
||||
if (api?.basePath) {
|
||||
@@ -194,21 +69,38 @@ const Login: React.FC = () => {
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
>
|
||||
<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}
|
||||
</>
|
||||
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
|
||||
<View></View>
|
||||
<View>
|
||||
<View className="mb-4">
|
||||
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
|
||||
<Text className="text-neutral-500 mb-2">
|
||||
Server: {api.basePath}
|
||||
</Text>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
setServerURL("");
|
||||
}}
|
||||
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>
|
||||
<Text className="text-neutral-500">
|
||||
Log in to any user account
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
@@ -245,18 +137,13 @@ const Login: React.FC = () => {
|
||||
<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>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
className="mt-auto mb-2"
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
@@ -267,22 +154,14 @@ const Login: React.FC = () => {
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<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")}
|
||||
/>
|
||||
<View className="flex flex-col px-4 justify-between h-full">
|
||||
<View></View>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Enter the URL to your Jellyfin server
|
||||
Connect to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Server URL"
|
||||
@@ -294,20 +173,13 @@ const Login: React.FC = () => {
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
Make sure to include http or https
|
||||
<Text className="opacity-30">
|
||||
Server URL requires http or https
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => await handleConnect(serverURL)}
|
||||
className="w-full grow"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</View>
|
||||
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
|
||||
Connect
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
|
||||
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 91 KiB |
BIN
assets/images/icon.jpg
Normal file
|
After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/icon_512x512.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
@@ -1,65 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
BIN
assets/images/splash.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 158 KiB |
@@ -1,3 +0,0 @@
|
||||
export * from "./mmkv";
|
||||
export * from "./number";
|
||||
export * from "./string";
|
||||
@@ -1,17 +0,0 @@
|
||||
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 {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
declare global {
|
||||
interface Number {
|
||||
bytesToReadable(): string;
|
||||
secondsToMilliseconds(): number
|
||||
minutesToMilliseconds(): number
|
||||
hoursToMilliseconds(): number
|
||||
}
|
||||
}
|
||||
|
||||
Number.prototype.bytesToReadable = function () {
|
||||
const bytes = this.valueOf();
|
||||
const gb = bytes / 1e9;
|
||||
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
|
||||
const mb = bytes / 1024.0 / 1024.0;
|
||||
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
||||
|
||||
const kb = bytes / 1024.0;
|
||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||
|
||||
return `${bytes.toFixed(2)} B`;
|
||||
}
|
||||
|
||||
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 {};
|
||||
@@ -1,16 +0,0 @@
|
||||
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 {};
|
||||
@@ -1,47 +1,53 @@
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
item: BaseItemDto;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
selected: number;
|
||||
}
|
||||
|
||||
export const AudioTrackSelector: React.FC<Props> = ({
|
||||
source,
|
||||
item,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const audioStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source]
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[item],
|
||||
);
|
||||
|
||||
const selectedAudioSteam = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected]
|
||||
[audioStreams, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
|
||||
if (index !== undefined && index !== null) onChange(index);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="" numberOfLines={1}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{tc(selectedAudioSteam?.DisplayTitle, 13)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { atom, useAtom } from "jotai";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
};
|
||||
|
||||
export const BITRATES: Bitrate[] = [
|
||||
const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
@@ -16,12 +16,10 @@ export const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "8 Mb/s",
|
||||
value: 8000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
@@ -35,62 +33,46 @@ export const BITRATES: Bitrate[] = [
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
},
|
||||
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||
];
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
selected?: Bitrate | null;
|
||||
inverted?: boolean | null;
|
||||
selected: Bitrate;
|
||||
}
|
||||
|
||||
export const BitrateSelector: React.FC<Props> = ({
|
||||
onChange,
|
||||
selected,
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.sort(
|
||||
(a, b) => (a.value || Infinity) - (b.value || Infinity)
|
||||
);
|
||||
return BITRATES.sort(
|
||||
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text style={{}} className="" numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="center"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{sorted.map((b) => (
|
||||
{BITRATES?.map((b, index: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={b.key}
|
||||
key={index.toString()}
|
||||
onSelect={() => {
|
||||
onChange(b);
|
||||
}}
|
||||
|
||||
@@ -3,15 +3,14 @@ import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
onPress?: () => void;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
children?: string | ReactNode;
|
||||
loading?: boolean;
|
||||
color?: "purple" | "red" | "black" | "transparent";
|
||||
color?: "purple" | "red" | "black";
|
||||
iconRight?: ReactNode;
|
||||
iconLeft?: ReactNode;
|
||||
justify?: "center" | "between";
|
||||
@@ -38,8 +37,6 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
return "bg-red-600";
|
||||
case "black":
|
||||
return "bg-neutral-900 border border-neutral-800";
|
||||
case "transparent":
|
||||
return "bg-transparent";
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
@@ -47,7 +44,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
<TouchableOpacity
|
||||
className={`
|
||||
p-3 rounded-xl items-center justify-center
|
||||
${(loading || disabled) && "opacity-50"}
|
||||
${loading || (disabled && "opacity-50")}
|
||||
${colorClasses}
|
||||
${className}
|
||||
`}
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
CastButton,
|
||||
CastContext,
|
||||
useCastDevice,
|
||||
useDevices,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import GoogleCast from "react-native-google-cast";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
type Props = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
background?: "blur" | "transparent";
|
||||
}
|
||||
};
|
||||
|
||||
export const Chromecast: React.FC<Props> = ({
|
||||
width = 48,
|
||||
height = 48,
|
||||
background = "transparent",
|
||||
...props
|
||||
}) => {
|
||||
export const Chromecast: React.FC<Props> = ({ width = 48, height = 48 }) => {
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -41,43 +31,9 @@ export const Chromecast: React.FC<Props> = ({
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
() =>
|
||||
Platform.OS === "android" ? (
|
||||
<CastButton tintColor="transparent" />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
[Platform.OS]
|
||||
);
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<RoundButton
|
||||
size="large"
|
||||
className="mr-2"
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundButton
|
||||
size="large"
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<CastButton style={{ tintColor: "white", height, width }} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,103 +1,61 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
import React from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
useEpisodePoster?: boolean;
|
||||
size?: "small" | "normal";
|
||||
showPlayButton?: boolean;
|
||||
};
|
||||
|
||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
item,
|
||||
useEpisodePoster = false,
|
||||
size = "normal",
|
||||
showPlayButton = false,
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
/**
|
||||
* Get horizontal poster for movie and episode, with failover to primary.
|
||||
*/
|
||||
const url = useMemo(() => {
|
||||
if (!api) return;
|
||||
if (item.Type === "Episode" && useEpisodePoster) {
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag)
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
else
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Movie") {
|
||||
if (item.ImageTags?.["Thumb"])
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||
else
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Program") {
|
||||
if (item.ImageTags?.["Thumb"])
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||
else
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
}, [item]);
|
||||
const url = useMemo(
|
||||
() =>
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 176 * 2,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
const startDate = new Date(item.StartDate || "");
|
||||
const endDate = new Date(item.EndDate || "");
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
} else {
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}
|
||||
}, [item]);
|
||||
const [progress, setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
|
||||
if (!url)
|
||||
return (
|
||||
<View className="aspect-video border border-neutral-800 w-44"></View>
|
||||
<View className="w-44 aspect-video border border-neutral-800"></View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`
|
||||
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
|
||||
${size === "small" ? "w-32" : "w-44"}
|
||||
`}
|
||||
>
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{showPlayButton && (
|
||||
<View className="absolute inset-0 flex items-center justify-center">
|
||||
<Ionicons name="play-circle" size={40} color="white" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="w-44 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{!progress && <WatchedIndicator item={item} />}
|
||||
{progress > 0 && (
|
||||
<>
|
||||
<View
|
||||
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
||||
style={{
|
||||
width: `100%`,
|
||||
}}
|
||||
className={`absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
||||
></View>
|
||||
<View
|
||||
style={{
|
||||
|
||||
274
components/CurrentlyPlayingBar.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Video from "react-native-video";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
export const CurrentlyPlayingBar: React.FC = () => {
|
||||
const segments = useSegments();
|
||||
const {
|
||||
currentlyPlaying,
|
||||
pauseVideo,
|
||||
playVideo,
|
||||
setCurrentlyPlayingState,
|
||||
stopPlayback,
|
||||
setIsPlaying,
|
||||
isPlaying,
|
||||
videoRef,
|
||||
onProgress,
|
||||
} = usePlayback();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const aBottom = useSharedValue(0);
|
||||
const aPadding = useSharedValue(0);
|
||||
const aHeight = useSharedValue(100);
|
||||
const router = useRouter();
|
||||
const animatedOuterStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
bottom: withTiming(aBottom.value, { duration: 500 }),
|
||||
height: withTiming(aHeight.value, { duration: 500 }),
|
||||
padding: withTiming(aPadding.value, { duration: 500 }),
|
||||
};
|
||||
});
|
||||
|
||||
const aPaddingBottom = useSharedValue(30);
|
||||
const aPaddingInner = useSharedValue(12);
|
||||
const aBorderRadiusBottom = useSharedValue(12);
|
||||
const animatedInnerStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
padding: withTiming(aPaddingInner.value, { duration: 500 }),
|
||||
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
|
||||
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
|
||||
duration: 500,
|
||||
}),
|
||||
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
|
||||
duration: 500,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (segments.find((s) => s.includes("tabs"))) {
|
||||
// Tab screen - i.e. home
|
||||
aBottom.value = Platform.OS === "ios" ? 78 : 50;
|
||||
aHeight.value = 80;
|
||||
aPadding.value = 8;
|
||||
aPaddingBottom.value = 8;
|
||||
aPaddingInner.value = 8;
|
||||
} else {
|
||||
// Inside a normal screen
|
||||
aBottom.value = Platform.OS === "ios" ? 0 : 0;
|
||||
aHeight.value = Platform.OS === "ios" ? 110 : 80;
|
||||
aPadding.value = Platform.OS === "ios" ? 0 : 8;
|
||||
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
|
||||
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
|
||||
}
|
||||
}, [segments]);
|
||||
|
||||
const startPosition = useMemo(
|
||||
() =>
|
||||
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(
|
||||
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
|
||||
)
|
||||
: 0,
|
||||
[currentlyPlaying?.item]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item: currentlyPlaying?.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
}),
|
||||
[currentlyPlaying?.item, api]
|
||||
);
|
||||
|
||||
if (!api || !currentlyPlaying) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[animatedOuterStyle]}
|
||||
className="absolute left-0 w-screen"
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS === "android" ? 60 : 100}
|
||||
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
|
||||
className={`h-full w-full rounded-xl overflow-hidden ${
|
||||
Platform.OS === "android" && "bg-black"
|
||||
}`}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
|
||||
animatedInnerStyle,
|
||||
]}
|
||||
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-4 shrink">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
}}
|
||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||
${
|
||||
currentlyPlaying.item?.Type === "Audio"
|
||||
? "aspect-square"
|
||||
: "aspect-video"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{currentlyPlaying?.url && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
allowsExternalPlayback
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
playWhenInactive={true}
|
||||
playInBackground={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
controls={false}
|
||||
pictureInPicture={true}
|
||||
poster={
|
||||
backdropUrl && currentlyPlaying.item?.Type === "Audio"
|
||||
? backdropUrl
|
||||
: undefined
|
||||
}
|
||||
debug={{
|
||||
enable: true,
|
||||
thread: true,
|
||||
}}
|
||||
paused={!isPlaying}
|
||||
onProgress={(e) => onProgress(e)}
|
||||
subtitleStyle={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
source={{
|
||||
uri: currentlyPlaying.url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
}}
|
||||
onBuffer={(e) =>
|
||||
e.isBuffering ? console.log("Buffering...") : null
|
||||
}
|
||||
onFullscreenPlayerDidDismiss={() => {}}
|
||||
onFullscreenPlayerDidPresent={() => {}}
|
||||
onPlaybackStateChanged={(e) => {
|
||||
if (e.isPlaying) {
|
||||
setIsPlaying(true);
|
||||
} else if (e.isSeeking) {
|
||||
return;
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={2000}
|
||||
onError={(e) => {
|
||||
console.log(e);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Video playback error: " + JSON.stringify(e)
|
||||
);
|
||||
Alert.alert("Error", "Cannot play this video file.");
|
||||
setIsPlaying(false);
|
||||
// setCurrentlyPlaying(null);
|
||||
}}
|
||||
renderLoader={
|
||||
currentlyPlaying.item?.Type !== "Audio" && (
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View className="shrink text-xs">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (currentlyPlaying.item?.Type === "Audio")
|
||||
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||
else router.push(`/items/${currentlyPlaying.item?.Id}`);
|
||||
}}
|
||||
>
|
||||
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||
</TouchableOpacity>
|
||||
{currentlyPlaying.item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(
|
||||
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
|
||||
);
|
||||
}}
|
||||
className="text-xs opacity-50"
|
||||
>
|
||||
<Text>{currentlyPlaying.item.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{currentlyPlaying.item?.Type === "Movie" && (
|
||||
<View>
|
||||
<Text className="text-xs opacity-50">
|
||||
{currentlyPlaying.item?.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{currentlyPlaying.item?.Type === "Audio" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||
}}
|
||||
>
|
||||
<Text className="text-xs opacity-50">
|
||||
{currentlyPlaying.item?.Album}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (isPlaying) pauseVideo();
|
||||
else playVideo();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Ionicons name="pause" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="play" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
stopPlayback();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
@@ -1,405 +1,138 @@
|
||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||
import download from "@/utils/profiles/download";
|
||||
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Href, router, useFocusEffect } from "expo-router";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, View, ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
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";
|
||||
}
|
||||
type DownloadProps = {
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
};
|
||||
|
||||
export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
items,
|
||||
MissingDownloadIconComponent,
|
||||
DownloadedIconComponent,
|
||||
title = "Download",
|
||||
subtitle = "",
|
||||
size = "default",
|
||||
...props
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
item,
|
||||
playbackUrl,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [process] = useAtom(runningProcesses);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||
const { startRemuxing } = useRemuxHlsToMp4();
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
>(undefined);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
||||
|
||||
const { data: playbackInfo, isLoading } = useQuery({
|
||||
queryKey: ["playbackInfo", item.Id],
|
||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
||||
});
|
||||
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user]
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === "optimized",
|
||||
[settings]
|
||||
);
|
||||
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
|
||||
queryKey: ["downloaded", item.Id],
|
||||
queryFn: async () => {
|
||||
if (!item.Id) return false;
|
||||
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const handlePresentModalPress = useCallback(() => {
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
|
||||
const itemsNotDownloaded = useMemo(
|
||||
() =>
|
||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||
[items, downloadedFiles]
|
||||
);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (itemIds.length == 1)
|
||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
||||
return (
|
||||
((itemIds.length -
|
||||
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
||||
itemIds.length) *
|
||||
100
|
||||
);
|
||||
}, [queue, itemsProcesses, itemIds]);
|
||||
|
||||
const itemsQueued = useMemo(() => {
|
||||
return (
|
||||
itemsNotDownloaded.length > 0 &&
|
||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||
);
|
||||
}, [queue, itemsNotDownloaded]);
|
||||
const navigateToDownloads = () => router.push("/downloads");
|
||||
|
||||
const onDownloadedPress = () => {
|
||||
const firstItem = items?.[0];
|
||||
router.push(
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
},
|
||||
} as Href)
|
||||
);
|
||||
};
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||
else {
|
||||
queueActions.enqueue(
|
||||
queue,
|
||||
setQueue,
|
||||
...itemsNotDownloaded.map((item) => ({
|
||||
id: item.Id!,
|
||||
execute: async () => await initiateDownload(item),
|
||||
item,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error("You are not allowed to download files.");
|
||||
}
|
||||
}, [
|
||||
queue,
|
||||
setQueue,
|
||||
itemsNotDownloaded,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
]);
|
||||
|
||||
const initiateDownload = useCallback(
|
||||
async (...items: BaseItemDto[]) => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
items.some((p) => !p.Id) ||
|
||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||
) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
);
|
||||
}
|
||||
let mediaSource = selectedMediaSource;
|
||||
let audioIndex: number | undefined = selectedAudioStream;
|
||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||
|
||||
for (const item of items) {
|
||||
if (itemsNotDownloaded.length > 1) {
|
||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||
item,
|
||||
settings!
|
||||
));
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: 0,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: download,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
Alert.alert(
|
||||
"Something went wrong",
|
||||
"Could not get stream url from Jellyfin"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { mediaSource: source, url } = res;
|
||||
|
||||
if (!url || !source) throw new Error("No url");
|
||||
|
||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||
|
||||
if (usingOptimizedServer) {
|
||||
await startBackgroundDownload(url, item, source);
|
||||
} else {
|
||||
await startRemuxing(item, url, source);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
itemsNotDownloaded,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
startRemuxing,
|
||||
]
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, itemsNotDownloaded, settings])
|
||||
);
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (processes && itemsProcesses.length > 0) {
|
||||
return progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
const data: BaseItemDto[] = JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
);
|
||||
} 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 data.some((d) => d.Id === item.Id);
|
||||
},
|
||||
enabled: !!item.Id,
|
||||
});
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton size={size} onPress={onButtonPress}>
|
||||
{renderButtonContent()}
|
||||
</RoundButton>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
onChange={handleSheetChanges}
|
||||
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">
|
||||
{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}
|
||||
/>
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
<View className="flex flex-col space-y-2">
|
||||
<AudioTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={acceptDownloadOptions}
|
||||
color="purple"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<View className="opacity-70 text-center w-full flex items-center">
|
||||
<Text className="text-xs">
|
||||
{usingOptimizedServer
|
||||
? "Using optimized server"
|
||||
: "Using default method"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
if (isLoading || isLoadingDownloaded) {
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const DownloadSingleItem: React.FC<{
|
||||
size?: "default" | "large";
|
||||
item: BaseItemDto;
|
||||
}> = ({ item, size = "default" }) => {
|
||||
return (
|
||||
<DownloadItems
|
||||
size={size}
|
||||
title="Download Episode"
|
||||
subtitle={item.Name!}
|
||||
items={[item]}
|
||||
MissingDownloadIconComponent={() => (
|
||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (process && process?.item.Id === item.Id) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
{process.progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={process.progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (queue.some((i) => i.id === item.Id)) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="hourglass" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (downloaded) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id!,
|
||||
execute: async () => {
|
||||
await startRemuxing();
|
||||
},
|
||||
item,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// GenreTags.tsx
|
||||
import React from "react";
|
||||
import {View, ViewProps} from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface TagProps {
|
||||
tags?: string[];
|
||||
textClass?: ViewProps["className"]
|
||||
}
|
||||
|
||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
|
||||
text,
|
||||
textClass,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||
<Text className={textClass}>{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 gap-1 ${props.className}`} {...props}>
|
||||
{tags.map((tag, idx) => (
|
||||
<View>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -10,16 +10,15 @@ type ItemCardProps = {
|
||||
|
||||
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||
return (
|
||||
<View className="mt-2 flex flex-col">
|
||||
<View className="mt-2 flex flex-col h-12">
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={1} className="">
|
||||
{item.Name}
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{item.SeriesName}
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||
{item.Name}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
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";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
audioIndex: number | undefined;
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
useImageColors({ item });
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast background="blur" width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadSingleItem item={item} size="large" />
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}, [item.Type, orientation]);
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
|
||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
|
||||
useState<number | undefined>(selectedOptions?.subtitleIndex);
|
||||
|
||||
useEffect(() => {
|
||||
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
|
||||
if (isTranscoding) {
|
||||
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
|
||||
const subHelper = new SubtitleHelper(
|
||||
selectedOptions?.mediaSource?.MediaStreams ?? []
|
||||
);
|
||||
|
||||
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
|
||||
selectedOptions?.subtitleIndex
|
||||
);
|
||||
|
||||
setSelectedOptions((prev) => ({
|
||||
...prev!,
|
||||
subtitleIndex: newSubtitleIndex ?? -1,
|
||||
}));
|
||||
}
|
||||
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
|
||||
setSelectedOptions((prev) => ({
|
||||
...prev!,
|
||||
subtitleIndex: previouslyChosenSubtitleIndex,
|
||||
}));
|
||||
}
|
||||
setIsTranscoding(isTranscoding);
|
||||
}, [selectedOptions?.bitrate]);
|
||||
|
||||
if (!selectedOptions) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
<View style={[{ flex: 1 }]}>
|
||||
<ItemImage
|
||||
variant={
|
||||
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||
}
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col bg-transparent shrink">
|
||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||
<ItemHeader item={item} className="mb-4" />
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||
<BitrateSelector
|
||||
className="mr-1"
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val }
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
className="mr-1"
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
}
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
className="mr-1"
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
}
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
isTranscoding={isTranscoding}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
}
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayButton
|
||||
className="grow"
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
|
||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||
|
||||
{item.People && item.People.length > 0 && (
|
||||
<View className="mb-4">
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
actorId={person.Id!}
|
||||
className="mb-4"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,46 +0,0 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { Ratings } from "./Ratings";
|
||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import React from "react";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
if (!item)
|
||||
return (
|
||||
<View
|
||||
className="flex flex-col space-y-1.5 w-full items-start h-32"
|
||||
{...props}
|
||||
>
|
||||
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
||||
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
|
||||
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
|
||||
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex flex-col" {...props}>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Ratings item={item} className="mb-2" />
|
||||
{item.Type === "Episode" && (
|
||||
<>
|
||||
<EpisodeTitleHeader item={item} />
|
||||
<GenreTags genres={item.Genres!} />
|
||||
</>
|
||||
)}
|
||||
{item.Type === "Movie" && (
|
||||
<>
|
||||
<MoviesTitleHeader item={item} />
|
||||
<GenreTags genres={item.Genres!} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,236 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
MediaSourceInfo,
|
||||
type MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Badge } from "./Badge";
|
||||
import { Text } from "./common/Text";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetView,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface Props {
|
||||
source?: MediaSourceInfo;
|
||||
}
|
||||
|
||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
return (
|
||||
<View className="px-4 mt-2 mb-4">
|
||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
<Text className="text-purple-600">More details</Text>
|
||||
</TouchableOpacity>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
snapPoints={["80%"]}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||
<AudioStreamInfo
|
||||
audioStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Audio"
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">Subtitles</Text>
|
||||
<SubtitleStreamInfo
|
||||
subtitleStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Subtitle"
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const SubtitleStreamInfo = ({
|
||||
subtitleStreams,
|
||||
}: {
|
||||
subtitleStreams: MediaStream[];
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
{subtitleStreams.map((stream, index) => (
|
||||
<View key={stream.Index} className="flex flex-col">
|
||||
<Text className="text-xs mb-3 text-neutral-400">
|
||||
{stream.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
}
|
||||
text={stream.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
text={stream.Codec}
|
||||
iconLeft={
|
||||
<Ionicons name="layers-outline" size={16} color="white" />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
{audioStreams.map((audioStreams, index) => (
|
||||
<View key={index} className="flex flex-col">
|
||||
<Text className="mb-3 text-neutral-400 text-xs">
|
||||
{audioStreams.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
}
|
||||
text={audioStreams.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="musical-notes-outline"
|
||||
size={16}
|
||||
color="white"
|
||||
/>
|
||||
}
|
||||
text={audioStreams.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
||||
text={audioStreams.ChannelLayout}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
}
|
||||
text={formatBitrate(audioStreams.BitRate)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
if (!source) return null;
|
||||
|
||||
const videoStream = useMemo(() => {
|
||||
return source.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Video"
|
||||
) as MediaStream;
|
||||
}, [source.MediaStreams]);
|
||||
|
||||
return (
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
text={formatFileSize(source.Size)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="color-palette-outline" size={16} color="white" />
|
||||
}
|
||||
text={videoStream.VideoRange}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="code-working-outline" size={16} color="white" />
|
||||
}
|
||||
text={videoStream.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
}
|
||||
text={formatBitrate(videoStream.BitRate)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number | null) => {
|
||||
if (!bytes) return "N/A";
|
||||
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
if (bytes === 0) return "0 Byte";
|
||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
const formatBitrate = (bitrate?: number | null) => {
|
||||
if (!bitrate) return "N/A";
|
||||
|
||||
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
||||
if (bitrate === 0) return "0 bps";
|
||||
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
||||
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
@@ -21,13 +21,9 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
||||
{...props}
|
||||
>
|
||||
<View className="flex flex-col overflow-visible">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-bold ">{title}</Text>
|
||||
{subTitle && (
|
||||
<Text uiTextView selectable className="text-xs text-neutral-400">
|
||||
{subTitle}
|
||||
</Text>
|
||||
)}
|
||||
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
||||
</View>
|
||||
{iconAfter}
|
||||
</View>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
onChange: (value: MediaSourceInfo) => void;
|
||||
selected?: MediaSourceInfo | null;
|
||||
}
|
||||
|
||||
export const MediaSourceSelector: React.FC<Props> = ({
|
||||
item,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const selectedName = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||
(x) => x.Type === "Video"
|
||||
)?.DisplayTitle || "",
|
||||
[item, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
||||
<Text numberOfLines={1}>{selectedName}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||
{item.MediaSources?.map((source, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
onChange(source);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||
source.Size
|
||||
)}`}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const name = (name?: string | null) => {
|
||||
if (name && name.length > 40)
|
||||
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
|
||||
return name;
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
actorId: string;
|
||||
currentItem: BaseItemDto;
|
||||
}
|
||||
|
||||
export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
actorId,
|
||||
currentItem,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: actor } = useQuery({
|
||||
queryKey: ["actor", actorId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return null;
|
||||
return await getUserItemData({
|
||||
api,
|
||||
userId: user.Id,
|
||||
itemId: actorId,
|
||||
});
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!actorId,
|
||||
});
|
||||
|
||||
const { data: items, isLoading } = useQuery({
|
||||
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
personIds: [actorId],
|
||||
limit: 20,
|
||||
sortOrder: ["Descending"],
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
recursive: true,
|
||||
fields: ["ParentId", "PrimaryImageAspectRatio"],
|
||||
sortBy: ["PremiereDate"],
|
||||
collapseBoxSetItems: false,
|
||||
excludeItemIds: [currentItem.SeriesId || "", currentItem.Id || ""],
|
||||
});
|
||||
|
||||
// Remove duplicates based on item ID
|
||||
const uniqueItems =
|
||||
response.data.Items?.reduce((acc, current) => {
|
||||
const x = acc.find((item) => item.Id === current.Id);
|
||||
if (!x) {
|
||||
return acc.concat([current]);
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}, [] as BaseItemDto[]) || [];
|
||||
|
||||
return uniqueItems;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!actorId,
|
||||
});
|
||||
|
||||
if (items?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="text-lg font-bold mb-2 px-4">
|
||||
More with {actor?.Name}
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
height={247}
|
||||
renderItem={(item: BaseItemDto, idx: number) => (
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
item={item}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
37
components/OfflineVideoPlayer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import Video, { VideoRef } from "react-native-video";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
|
||||
const onError = (error: any) => {
|
||||
console.error("Video Error: ", error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.resume();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.presentFullscreenPlayer();
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Video
|
||||
source={{
|
||||
uri: url,
|
||||
isNetwork: false,
|
||||
}}
|
||||
ref={videoRef}
|
||||
onError={onError}
|
||||
ignoreSilentSwitch="ignore"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,37 +5,34 @@ import { useState } from "react";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
text?: string | null;
|
||||
characterLimit?: number;
|
||||
}
|
||||
|
||||
export const OverviewText: React.FC<Props> = ({
|
||||
text,
|
||||
characterLimit = 100,
|
||||
...props
|
||||
}) => {
|
||||
const [limit, setLimit] = useState(characterLimit);
|
||||
const LIMIT = 140;
|
||||
|
||||
export const OverviewText: React.FC<Props> = ({ text, ...props }) => {
|
||||
const [limit, setLimit] = useState(LIMIT);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="text-lg font-bold mb-2">Overview</Text>
|
||||
if (text.length > LIMIT)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setLimit((prev) =>
|
||||
prev === characterLimit ? text.length : characterLimit
|
||||
)
|
||||
setLimit((prev) => (prev === LIMIT ? text.length : LIMIT))
|
||||
}
|
||||
>
|
||||
<View>
|
||||
<View {...props} className="">
|
||||
<Text>{tc(text, limit)}</Text>
|
||||
{text.length > characterLimit && (
|
||||
<Text className="text-purple-600 mt-1">
|
||||
{limit === characterLimit ? "Show more" : "Show less"}
|
||||
</Text>
|
||||
)}
|
||||
<Text className="text-purple-600 mt-1">
|
||||
{limit === LIMIT ? "Show more" : "Show less"}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { type PropsWithChildren, type ReactElement } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollViewOffset,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
const HEADER_HEIGHT = 400;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
logo?: ReactElement;
|
||||
episodePoster?: ReactElement;
|
||||
headerHeight?: number;
|
||||
}
|
||||
}>;
|
||||
|
||||
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
export const ParallaxScrollView: React.FC<Props> = ({
|
||||
children,
|
||||
headerImage,
|
||||
episodePoster,
|
||||
headerHeight = 400,
|
||||
logo,
|
||||
...props
|
||||
}: Props) => {
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(scrollRef);
|
||||
@@ -32,23 +32,25 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-headerHeight, 0, headerHeight],
|
||||
[-headerHeight / 2, 0, headerHeight * 0.75]
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-headerHeight, 0, headerHeight],
|
||||
[2, 1, 1]
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[2, 1, 1],
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const inset = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View className="flex-1" {...props}>
|
||||
<View className="flex-1">
|
||||
<Animated.ScrollView
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -56,14 +58,32 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
ref={scrollRef}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
|
||||
style={{
|
||||
top: inset.top + 17,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
className="drop-shadow-2xl"
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color="#077DF2"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View
|
||||
className="absolute right-4 z-50 bg-black rounded-full p-0.5"
|
||||
style={{
|
||||
top: inset.top + 17,
|
||||
}}
|
||||
>
|
||||
<Chromecast width={22} height={22} />
|
||||
</View>
|
||||
|
||||
{logo && (
|
||||
<View
|
||||
style={{
|
||||
top: headerHeight - 200,
|
||||
height: 130,
|
||||
}}
|
||||
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
|
||||
>
|
||||
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
|
||||
{logo}
|
||||
</View>
|
||||
)}
|
||||
@@ -71,7 +91,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
height: headerHeight,
|
||||
height: HEADER_HEIGHT,
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerAnimatedStyle,
|
||||
@@ -79,35 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
top: -50,
|
||||
}}
|
||||
className="relative flex-1 bg-transparent pb-24"
|
||||
>
|
||||
<LinearGradient
|
||||
// Background Linear Gradient
|
||||
colors={["transparent", "rgba(0,0,0,1)"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: -150,
|
||||
height: 200,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
// Background Linear Gradient
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 50,
|
||||
height: "100%",
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
/>
|
||||
<View className="flex-1 overflow-hidden bg-black pb-24">
|
||||
{children}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React from "react";
|
||||
import { Platform, View, ViewProps } from "react-native";
|
||||
interface Props extends ViewProps {
|
||||
blurAmount?: number;
|
||||
blurType?: "light" | "dark" | "xlight";
|
||||
}
|
||||
|
||||
/**
|
||||
* BlurView for iOS and simple View for Android
|
||||
*/
|
||||
export const PlatformBlurView: React.FC<Props> = ({
|
||||
blurAmount = 100,
|
||||
blurType = "light",
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<BlurView style={style} intensity={blurAmount} {...props}>
|
||||
{children}
|
||||
</BlurView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[{ backgroundColor: "rgba(50, 50, 50, 0.9)" }, style]}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,389 +1,85 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
interpolateColor,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
const MIN_PLAYBACK_WIDTH = 15;
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
...props
|
||||
}: Props) => {
|
||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const { currentlyPlaying, setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const startWidth = useSharedValue(0);
|
||||
const targetWidth = useSharedValue(0);
|
||||
const endColor = useSharedValue(colorAtom);
|
||||
const startColor = useSharedValue(colorAtom);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string, bitrateValue: number | undefined) => {
|
||||
if (!bitrateValue) {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
return;
|
||||
}
|
||||
router.push(`/player/transcoding-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
});
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const onPress = async () => {
|
||||
if (!url || !item) return;
|
||||
|
||||
if (!client) {
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
return;
|
||||
}
|
||||
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
},
|
||||
async (selectedIndex: number | undefined) => {
|
||||
if (!api) return;
|
||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||
const isOpeningCurrentlyPlayingMedia =
|
||||
currentTitle && currentTitle === item?.Name;
|
||||
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
await CastContext.getPlayServicesState().then(async (state) => {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
// Get a new URL with the Chromecast device profile:
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: chromecastProfile,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: selectedOptions.audioIndex,
|
||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
"Client error",
|
||||
"Could not create stream for Chromecast"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: data?.url,
|
||||
contentType: "video/mp4",
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: url,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
startTime: 0,
|
||||
})
|
||||
.then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
});
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [
|
||||
item,
|
||||
client,
|
||||
settings,
|
||||
api,
|
||||
user,
|
||||
router,
|
||||
showActionSheetWithOptions,
|
||||
mediaStatus,
|
||||
selectedOptions,
|
||||
]);
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (userData && userData.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
return 0;
|
||||
}, [item]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => derivedTargetWidth.value,
|
||||
(newWidth) => {
|
||||
targetWidth.value = newWidth;
|
||||
widthProgress.value = 0;
|
||||
widthProgress.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||
});
|
||||
},
|
||||
[item]
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => colorAtom,
|
||||
(newColor) => {
|
||||
endColor.value = newColor;
|
||||
colorChangeProgress.value = 0;
|
||||
colorChangeProgress.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||
});
|
||||
},
|
||||
[colorAtom]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout_2 = setTimeout(() => {
|
||||
startColor.value = colorAtom;
|
||||
startWidth.value = targetWidth.value;
|
||||
}, ANIMATION_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout_2);
|
||||
};
|
||||
}, [colorAtom, item]);
|
||||
|
||||
/**
|
||||
* ANIMATED STYLES
|
||||
*/
|
||||
const animatedAverageStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.primary, endColor.value.primary]
|
||||
),
|
||||
}));
|
||||
|
||||
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.primary, endColor.value.primary]
|
||||
),
|
||||
}));
|
||||
|
||||
const animatedWidthStyle = useAnimatedStyle(() => ({
|
||||
width: `${interpolate(
|
||||
widthProgress.value,
|
||||
[0, 1],
|
||||
[startWidth.value, targetWidth.value]
|
||||
)}%`,
|
||||
}));
|
||||
|
||||
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||
color: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.text, endColor.value.text]
|
||||
),
|
||||
}));
|
||||
/**
|
||||
* *********************
|
||||
*/
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
animatedWidthStyle,
|
||||
{
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
onPress={onPress}
|
||||
iconRight={
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Ionicons name="play-circle" size={24} color="white" />
|
||||
{client && <Feather name="cast" size={22} color="white" />}
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
<CastButton tintColor="transparent" />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{/* <View className="mt-2 flex flex-row items-center">
|
||||
<Ionicons
|
||||
name="information-circle"
|
||||
size={12}
|
||||
className=""
|
||||
color={"#9BA1A6"}
|
||||
/>
|
||||
<Text className="text-neutral-500 ml-1">
|
||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||
</Text>
|
||||
</View> */}
|
||||
</View>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
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 { 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 { View, ViewProps } from "react-native";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
queryKey: ["item"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["resumeItems"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["continueWatching"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp"],
|
||||
});
|
||||
@@ -35,20 +32,45 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
queryKey: ["seasons"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
};
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
fillColor={item.UserData?.Played ? "primary" : undefined}
|
||||
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
||||
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
||||
size="large"
|
||||
/>
|
||||
<View>
|
||||
{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={30} 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={30} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||